@intellectif/lk-core 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +2 -2
  3. package/dist/{activity-DcxtpX7Z.d.cts → activity-HH8dxN1v.d.cts} +234 -4
  4. package/dist/{activity-DcxtpX7Z.d.ts → activity-HH8dxN1v.d.ts} +234 -4
  5. package/dist/{chunk-FJJ2GVBA.cjs → chunk-2CV6NU32.cjs} +19 -19
  6. package/dist/{chunk-FJJ2GVBA.cjs.map → chunk-2CV6NU32.cjs.map} +1 -1
  7. package/dist/{chunk-6P6XK2LI.cjs → chunk-COBK2RUX.cjs} +74 -39
  8. package/dist/chunk-COBK2RUX.cjs.map +1 -0
  9. package/dist/{chunk-IRUD73IQ.js → chunk-D3GP5MET.js} +683 -638
  10. package/dist/chunk-D3GP5MET.js.map +1 -0
  11. package/dist/{chunk-F7UOLKWV.cjs → chunk-D4TOPSHV.cjs} +518 -473
  12. package/dist/chunk-D4TOPSHV.cjs.map +1 -0
  13. package/dist/{chunk-VFTVZJO5.cjs → chunk-EOFWSGLU.cjs} +4 -4
  14. package/dist/{chunk-VFTVZJO5.cjs.map → chunk-EOFWSGLU.cjs.map} +1 -1
  15. package/dist/{chunk-S63IOKFP.js → chunk-MMEQKFFQ.js} +44 -9
  16. package/dist/chunk-MMEQKFFQ.js.map +1 -0
  17. package/dist/{chunk-OXF7VT7S.js → chunk-X5DAXBJI.js} +2 -2
  18. package/dist/{chunk-GOZGBVJP.js → chunk-Z7QQZ53L.js} +2 -2
  19. package/dist/{chunk-GOZGBVJP.js.map → chunk-Z7QQZ53L.js.map} +1 -1
  20. package/dist/{index-DRJqwO2y.d.ts → index-BrBBUb1i.d.ts} +141 -83
  21. package/dist/{index-BWpwZRQw.d.cts → index-DejnxQpK.d.cts} +141 -83
  22. package/dist/index.cjs +547 -127
  23. package/dist/index.cjs.map +1 -1
  24. package/dist/index.d.cts +145 -9
  25. package/dist/index.d.ts +145 -9
  26. package/dist/index.js +427 -7
  27. package/dist/index.js.map +1 -1
  28. package/dist/schemas.cjs +5 -3
  29. package/dist/schemas.cjs.map +1 -1
  30. package/dist/schemas.d.cts +2 -2
  31. package/dist/schemas.d.ts +2 -2
  32. package/dist/schemas.js +4 -2
  33. package/dist/scoring.cjs +3 -3
  34. package/dist/scoring.d.cts +33 -5
  35. package/dist/scoring.d.ts +33 -5
  36. package/dist/scoring.js +2 -2
  37. package/dist/xapi.cjs +3 -3
  38. package/dist/xapi.d.cts +1 -1
  39. package/dist/xapi.d.ts +1 -1
  40. package/dist/xapi.js +2 -2
  41. package/package.json +1 -1
  42. package/vectors/README.md +9 -3
  43. package/vectors/scoring.json +1258 -10
  44. package/dist/chunk-6P6XK2LI.cjs.map +0 -1
  45. package/dist/chunk-F7UOLKWV.cjs.map +0 -1
  46. package/dist/chunk-IRUD73IQ.js.map +0 -1
  47. package/dist/chunk-S63IOKFP.js.map +0 -1
  48. /package/dist/{chunk-OXF7VT7S.js.map → chunk-X5DAXBJI.js.map} +0 -0
package/dist/index.js CHANGED
@@ -11,19 +11,20 @@ import {
11
11
  gte,
12
12
  hasGrade,
13
13
  inspectWav,
14
+ isGradeInRange,
14
15
  outcomeFromGrade,
15
16
  outcomeFromUnscorable,
16
17
  roundGrade,
17
18
  score,
18
19
  validateSpeechAssessment
19
- } from "./chunk-S63IOKFP.js";
20
+ } from "./chunk-MMEQKFFQ.js";
20
21
  import {
21
22
  XAPIVerb,
22
23
  XAPI_VERB_DISPLAY,
23
24
  validateXAPIStatement,
24
25
  xAPIBuilder,
25
26
  xapiDefinitionFor
26
- } from "./chunk-OXF7VT7S.js";
27
+ } from "./chunk-X5DAXBJI.js";
27
28
  import {
28
29
  INTERACTIVE_VIDEO_ITEM_TYPES,
29
30
  ItemGroupSchema,
@@ -49,9 +50,10 @@ import {
49
50
  validateActivity,
50
51
  validateItemGroup,
51
52
  writtenResponseJsonSchema
52
- } from "./chunk-GOZGBVJP.js";
53
+ } from "./chunk-Z7QQZ53L.js";
53
54
  import {
54
55
  ActivitySchemaError,
56
+ AiPermissionsSchema,
55
57
  BlankConfigSchema,
56
58
  DICTATION_MAX_ACCEPTED_TRANSCRIPTS,
57
59
  DICTATION_MAX_EQUIVALENCES,
@@ -80,6 +82,7 @@ import {
80
82
  MultipleChoiceOptionMediaSchema,
81
83
  MultipleChoiceOptionSchema,
82
84
  NativeControlHintSchema,
85
+ PLACEHOLDER_RE,
83
86
  READ_ALOUD_MAX_DIMENSION_WEIGHT,
84
87
  READ_ALOUD_MAX_REFERENCE_LENGTH,
85
88
  READ_ALOUD_MAX_SECONDS,
@@ -134,7 +137,410 @@ import {
134
137
  registeredActivityTypes,
135
138
  withValidationScope,
136
139
  writtenResponseType
137
- } from "./chunk-IRUD73IQ.js";
140
+ } from "./chunk-D3GP5MET.js";
141
+
142
+ // src/ai.ts
143
+ var AI_EXPLANATION_TYPES = [
144
+ "multiple-choice",
145
+ "fill-in-the-blanks",
146
+ "gap-select",
147
+ "dictation"
148
+ ];
149
+ var AI_HINT_TYPES = [
150
+ "multiple-choice",
151
+ "fill-in-the-blanks",
152
+ "gap-select"
153
+ ];
154
+ var AI_TEXT_MAX_LENGTH = 2e3;
155
+ function aiSupports(activityType, feature) {
156
+ const types = feature === "hint" ? AI_HINT_TYPES : AI_EXPLANATION_TYPES;
157
+ return types.includes(activityType);
158
+ }
159
+ function aiAllowedByContent(data, feature) {
160
+ const permissions = data.ai;
161
+ if (typeof permissions !== "object" || permissions === null) {
162
+ return true;
163
+ }
164
+ const key = feature === "hint" ? "hints" : "explanations";
165
+ return permissions[key] !== false;
166
+ }
167
+ function aiGradeOf(result) {
168
+ const ratio = result.maxScore > 0 ? result.score / result.maxScore : 0;
169
+ let category = "partly-correct";
170
+ if (ratio >= 1 - 1e-9) {
171
+ category = "correct";
172
+ } else if (ratio <= 1e-9) {
173
+ category = "incorrect";
174
+ }
175
+ return { score: result.score, maxScore: result.maxScore, passed: result.passed, category };
176
+ }
177
+ function placeholderOrder(passage) {
178
+ return [...passage.matchAll(PLACEHOLDER_RE)].map((match) => match[1]);
179
+ }
180
+ function numberedPassage(passage) {
181
+ let position = 0;
182
+ return passage.replace(PLACEHOLDER_RE, () => {
183
+ position += 1;
184
+ return `[${position}]`;
185
+ });
186
+ }
187
+ function isAnswerOption(detail, chosen) {
188
+ switch (detail.outcome) {
189
+ case "correct":
190
+ case "incorrect-omission":
191
+ return true;
192
+ case "incorrect":
193
+ case "correct-omission":
194
+ return false;
195
+ default:
196
+ return chosen ? detail.correct : !detail.correct;
197
+ }
198
+ }
199
+ var firstString = (value) => Array.isArray(value) ? value[0] : value;
200
+ var listOf = (value) => value === void 0 ? null : Array.isArray(value) ? [...value] : [value];
201
+ function emptyResponse(type) {
202
+ switch (type) {
203
+ case "multiple-choice":
204
+ return { type: "multiple-choice", selectedOptionIds: [] };
205
+ case "fill-in-the-blanks":
206
+ return { type: "fill-in-the-blanks", answers: {} };
207
+ case "gap-select":
208
+ return { type: "gap-select", selections: {} };
209
+ case "dictation":
210
+ return { type: "dictation", text: "" };
211
+ default:
212
+ return null;
213
+ }
214
+ }
215
+ var base = (data) => ({
216
+ activityId: data.id,
217
+ title: data.title,
218
+ ...typeof data.locale === "string" ? { locale: data.locale } : {}
219
+ });
220
+ function buildAiFacts(data, response, details) {
221
+ const byId = new Map((details ?? []).map((detail) => [detail.itemId, detail]));
222
+ const full = data.redacted !== true;
223
+ switch (data.type) {
224
+ case "multiple-choice": {
225
+ const item = data;
226
+ const chosen = new Set(
227
+ response?.type === "multiple-choice" ? response.selectedOptionIds : []
228
+ );
229
+ return {
230
+ ...base(data),
231
+ activityType: "multiple-choice",
232
+ question: item.question,
233
+ mode: item.mode,
234
+ options: item.options.map((option) => {
235
+ const detail = byId.get(option.id);
236
+ const wasChosen = chosen.has(option.id);
237
+ let correct = null;
238
+ if (full && typeof option.isCorrect === "boolean") {
239
+ correct = option.isCorrect;
240
+ } else if (detail !== void 0) {
241
+ correct = isAnswerOption(detail, wasChosen);
242
+ }
243
+ return {
244
+ id: option.id,
245
+ text: option.text,
246
+ chosen: wasChosen,
247
+ correct,
248
+ ...typeof option.feedback === "string" ? { feedback: option.feedback } : {}
249
+ };
250
+ })
251
+ };
252
+ }
253
+ case "fill-in-the-blanks": {
254
+ const item = data;
255
+ const answers = response?.type === "fill-in-the-blanks" ? response.answers : {};
256
+ const blanks = new Map(item.blanks.map((blank) => [blank.id, blank]));
257
+ return {
258
+ ...base(data),
259
+ activityType: "fill-in-the-blanks",
260
+ passage: numberedPassage(item.passage),
261
+ blanks: placeholderOrder(item.passage).map((id, index) => {
262
+ const blank = blanks.get(id);
263
+ const detail = byId.get(id);
264
+ const accepted = full && Array.isArray(blank?.acceptedAnswers) ? [...blank.acceptedAnswers] : listOf(detail?.correctResponse);
265
+ return {
266
+ id,
267
+ position: index + 1,
268
+ typed: typeof answers[id] === "string" ? answers[id] : "",
269
+ accepted,
270
+ correct: detail === void 0 ? null : detail.correct,
271
+ ...typeof blank?.hint === "string" ? { hint: blank.hint } : {},
272
+ ...typeof blank?.feedback === "string" ? { feedback: blank.feedback } : {}
273
+ };
274
+ })
275
+ };
276
+ }
277
+ case "gap-select": {
278
+ const item = data;
279
+ const selections = response?.type === "gap-select" ? response.selections : {};
280
+ const gaps = new Map(item.gaps.map((gap) => [gap.id, gap]));
281
+ const banks = new Map((item.banks ?? []).map((bank) => [bank.id, bank]));
282
+ return {
283
+ ...base(data),
284
+ activityType: "gap-select",
285
+ passage: numberedPassage(item.passage),
286
+ gaps: placeholderOrder(item.passage).map((id, index) => {
287
+ const gap = gaps.get(id);
288
+ const choices = gap?.choices ?? (gap?.bankId === void 0 ? [] : banks.get(gap.bankId)?.choices ?? []);
289
+ const textOf = (choiceId) => choiceId === void 0 ? null : choices.find((choice) => choice.id === choiceId)?.text ?? null;
290
+ const detail = byId.get(id);
291
+ const answerId = full && typeof gap?.correctChoiceId === "string" ? gap.correctChoiceId : firstString(detail?.correctResponse);
292
+ return {
293
+ id,
294
+ position: index + 1,
295
+ choices: choices.map((choice) => choice.text),
296
+ chosen: textOf(selections[id]),
297
+ answer: textOf(answerId),
298
+ correct: detail === void 0 ? null : detail.correct,
299
+ ...typeof gap?.feedback === "string" ? { feedback: gap.feedback } : {}
300
+ };
301
+ })
302
+ };
303
+ }
304
+ case "dictation": {
305
+ const item = data;
306
+ const typed = response?.type === "dictation" ? response.text : "";
307
+ const hasKey = full && typeof item.transcript === "string";
308
+ let words = null;
309
+ if (hasKey) {
310
+ words = alignDictation(item, typed).words.map((word) => ({
311
+ expected: word.reference,
312
+ typed: word.attempt,
313
+ status: word.status
314
+ }));
315
+ } else if (details !== null && details.length > 0) {
316
+ words = details.map((detail) => {
317
+ const expected = firstString(detail.correctResponse) ?? "";
318
+ const attempt = firstString(detail.learnerResponse) ?? "";
319
+ let status = "incorrect";
320
+ if (detail.correct) {
321
+ status = "correct";
322
+ } else if (attempt === "") {
323
+ status = "missing";
324
+ } else if (expected === "") {
325
+ status = "extra";
326
+ }
327
+ return { expected, typed: attempt, status };
328
+ });
329
+ }
330
+ return {
331
+ ...base(data),
332
+ activityType: "dictation",
333
+ transcript: hasKey ? item.transcript : null,
334
+ typed,
335
+ words
336
+ };
337
+ }
338
+ default:
339
+ return null;
340
+ }
341
+ }
342
+ function gradeLocally(data, response) {
343
+ if (data.redacted === true) {
344
+ return null;
345
+ }
346
+ try {
347
+ const result = score(data.type, data, response);
348
+ return { details: result.details, grade: aiGradeOf(result) };
349
+ } catch {
350
+ return null;
351
+ }
352
+ }
353
+ function aiExplanationRequest(input) {
354
+ const { data, response, outcome, learnerLocale } = input;
355
+ if (!aiSupports(data.type, "explanation") || !aiAllowedByContent(data, "explanation")) {
356
+ return null;
357
+ }
358
+ let details = null;
359
+ let grade = null;
360
+ if (outcome?.status === "scored") {
361
+ const { score: recorded, maxScore, passed } = outcome;
362
+ if (!isGradeInRange(recorded, maxScore)) {
363
+ return null;
364
+ }
365
+ details = outcome.details;
366
+ grade = aiGradeOf({ score: recorded, maxScore, passed });
367
+ } else if (response !== null) {
368
+ const local = gradeLocally(data, response);
369
+ if (local !== null) {
370
+ details = local.details;
371
+ grade = local.grade;
372
+ }
373
+ }
374
+ if (grade === null) {
375
+ return null;
376
+ }
377
+ const facts = buildAiFacts(data, response, details);
378
+ if (facts === null) {
379
+ return null;
380
+ }
381
+ return {
382
+ feature: "explanation",
383
+ facts,
384
+ grade,
385
+ ...learnerLocale !== void 0 ? { learnerLocale } : {}
386
+ };
387
+ }
388
+ function aiHintRequest(input) {
389
+ const { data, previousHints, learnerLocale } = input;
390
+ if (!aiSupports(data.type, "hint") || !aiAllowedByContent(data, "hint") || data.redacted === true) {
391
+ return null;
392
+ }
393
+ const response = input.response ?? emptyResponse(data.type);
394
+ const local = response === null ? null : gradeLocally(data, response);
395
+ const facts = buildAiFacts(data, response, local?.details ?? null);
396
+ if (facts === null) {
397
+ return null;
398
+ }
399
+ return {
400
+ feature: "hint",
401
+ facts,
402
+ hintNumber: previousHints.length + 1,
403
+ previousHints: [...previousHints],
404
+ ...learnerLocale !== void 0 ? { learnerLocale } : {}
405
+ };
406
+ }
407
+ var VERDICTS = ["correct", "partly-correct", "incorrect"];
408
+ function provenanceOf(raw) {
409
+ if (typeof raw !== "object" || raw === null) {
410
+ return void 0;
411
+ }
412
+ const kept = {};
413
+ for (const key of ["model", "promptHash", "generatedAt"]) {
414
+ const value = raw[key];
415
+ if (typeof value === "string" && value.length > 0 && value.length <= 200) {
416
+ kept[key] = value;
417
+ }
418
+ }
419
+ return Object.keys(kept).length > 0 ? kept : void 0;
420
+ }
421
+ function cleanText(text) {
422
+ 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();
423
+ }
424
+ function readAiTextResult(raw) {
425
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
426
+ return { ok: false, refusal: "malformed" };
427
+ }
428
+ const record = raw;
429
+ if (typeof record.text !== "string") {
430
+ return { ok: false, refusal: "malformed" };
431
+ }
432
+ if (record.verdict !== void 0 && !VERDICTS.includes(record.verdict)) {
433
+ return { ok: false, refusal: "malformed" };
434
+ }
435
+ const text = cleanText(record.text);
436
+ if (text === "") {
437
+ return { ok: false, refusal: "empty" };
438
+ }
439
+ if ([...text].length > AI_TEXT_MAX_LENGTH) {
440
+ return { ok: false, refusal: "too-long" };
441
+ }
442
+ const provenance = provenanceOf(record.provenance);
443
+ return {
444
+ ok: true,
445
+ result: {
446
+ text,
447
+ ...record.verdict !== void 0 ? { verdict: record.verdict } : {},
448
+ ...provenance !== void 0 ? { provenance } : {}
449
+ }
450
+ };
451
+ }
452
+ function checkAiExplanation(raw, request) {
453
+ const read = readAiTextResult(raw);
454
+ if (!read.ok) {
455
+ return read;
456
+ }
457
+ if (read.result.verdict !== void 0 && read.result.verdict !== request.grade.category) {
458
+ return { ok: false, refusal: "contradicts-grade" };
459
+ }
460
+ return read;
461
+ }
462
+ function checkAiHint(raw, request) {
463
+ const read = readAiTextResult(raw);
464
+ if (!read.ok) {
465
+ return read;
466
+ }
467
+ if (hintRevealsAnswer(request.facts, read.result.text)) {
468
+ return { ok: false, refusal: "reveals-answer" };
469
+ }
470
+ return read;
471
+ }
472
+ function fold(text) {
473
+ const folded = text.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
474
+ return folded === "" ? [] : folded.split(" ");
475
+ }
476
+ function containsWords(haystack, needle) {
477
+ if (needle.length === 0 || needle.length > haystack.length) {
478
+ return false;
479
+ }
480
+ for (let start = 0; start + needle.length <= haystack.length; start += 1) {
481
+ let match = true;
482
+ for (let offset = 0; offset < needle.length; offset += 1) {
483
+ if (haystack[start + offset] !== needle[offset]) {
484
+ match = false;
485
+ break;
486
+ }
487
+ }
488
+ if (match) {
489
+ return true;
490
+ }
491
+ }
492
+ return false;
493
+ }
494
+ var isShort = (words) => words.length === 1 && [...words[0]].length < 4;
495
+ function neighbours(passage, position) {
496
+ const marker = `[${position}]`;
497
+ const at = passage.indexOf(marker);
498
+ if (at === -1) {
499
+ return {};
500
+ }
501
+ const strip = (text) => text.replace(/\[\d+\]/g, " ");
502
+ const left = fold(strip(passage.slice(0, at)));
503
+ const right = fold(strip(passage.slice(at + marker.length)));
504
+ const before = left[left.length - 1];
505
+ const after = right[0];
506
+ return {
507
+ ...before !== void 0 ? { before } : {},
508
+ ...after !== void 0 ? { after } : {}
509
+ };
510
+ }
511
+ function hintRevealsAnswer(facts, hint) {
512
+ const words = fold(hint);
513
+ const reveals = (answer, passage, position) => {
514
+ const needle = fold(answer);
515
+ if (needle.length === 0) {
516
+ return false;
517
+ }
518
+ if (!isShort(needle) || passage === void 0 || position === void 0) {
519
+ return containsWords(words, needle);
520
+ }
521
+ const { before, after } = neighbours(passage, position);
522
+ if (before === void 0 && after === void 0) {
523
+ return containsWords(words, needle);
524
+ }
525
+ return before !== void 0 && containsWords(words, [before, ...needle]) || after !== void 0 && containsWords(words, [...needle, after]);
526
+ };
527
+ switch (facts.activityType) {
528
+ case "multiple-choice":
529
+ return facts.options.some((option) => option.correct === true && reveals(option.text));
530
+ case "fill-in-the-blanks":
531
+ return facts.blanks.some(
532
+ (blank) => (blank.accepted ?? []).some((answer) => reveals(answer, facts.passage, blank.position))
533
+ );
534
+ case "gap-select":
535
+ return facts.gaps.some(
536
+ (gap) => gap.answer !== null && reveals(gap.answer, facts.passage, gap.position)
537
+ );
538
+ case "dictation":
539
+ return false;
540
+ default:
541
+ return false;
542
+ }
543
+ }
138
544
 
139
545
  // src/content-hash.ts
140
546
  function canonicalJson(value, seen = /* @__PURE__ */ new Set()) {
@@ -1219,11 +1625,11 @@ function normalise(found) {
1219
1625
  }
1220
1626
 
1221
1627
  // src/redact.ts
1222
- function mergePolicy(base, overrides) {
1628
+ function mergePolicy(base2, overrides) {
1223
1629
  if (overrides === void 0) {
1224
- return base;
1630
+ return base2;
1225
1631
  }
1226
- const merged = { ...base };
1632
+ const merged = { ...base2 };
1227
1633
  for (const [key, override] of Object.entries(overrides)) {
1228
1634
  const current = merged[key];
1229
1635
  merged[key] = typeof override === "object" && typeof current === "object" ? { ...current, ...override } : override;
@@ -1487,7 +1893,11 @@ function composeTimelineScore(entryKey, items, policy) {
1487
1893
  return composeAssessmentScore([{ id: entryKey, weight: 1, items: own }], policy);
1488
1894
  }
1489
1895
  export {
1896
+ AI_EXPLANATION_TYPES,
1897
+ AI_HINT_TYPES,
1898
+ AI_TEXT_MAX_LENGTH,
1490
1899
  ActivitySchemaError,
1900
+ AiPermissionsSchema,
1491
1901
  BlankConfigSchema,
1492
1902
  DEFAULT_PASS_THRESHOLD,
1493
1903
  DICTATION_MAX_ACCEPTED_TRANSCRIPTS,
@@ -1555,11 +1965,19 @@ export {
1555
1965
  WrittenResponseRubricSchema,
1556
1966
  XAPIVerb,
1557
1967
  XAPI_VERB_DISPLAY,
1968
+ aiAllowedByContent,
1969
+ aiExplanationRequest,
1970
+ aiGradeOf,
1971
+ aiHintRequest,
1972
+ aiSupports,
1558
1973
  alignDictation,
1559
1974
  alignReadAloud,
1560
1975
  assertRedacted,
1561
1976
  assertRedactedItemGroup,
1977
+ buildAiFacts,
1562
1978
  canonicalJson,
1979
+ checkAiExplanation,
1980
+ checkAiHint,
1563
1981
  classifyBand,
1564
1982
  composeAssessmentScore,
1565
1983
  composeTimelineScore,
@@ -1589,6 +2007,7 @@ export {
1589
2007
  gte,
1590
2008
  hasGrade,
1591
2009
  hashSeed,
2010
+ hintRevealsAnswer,
1592
2011
  inspectWav,
1593
2012
  isInteractiveVideoItemType,
1594
2013
  isItemGroup,
@@ -1602,6 +2021,7 @@ export {
1602
2021
  outcomeFromUnscorable,
1603
2022
  planAttempt,
1604
2023
  planMediaBudgets,
2024
+ readAiTextResult,
1605
2025
  readAloudJsonSchema,
1606
2026
  readAloudType,
1607
2027
  readMediaProgress,