@longsightgroup/qti3-core 0.9.0 → 0.9.2

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 (51) hide show
  1. package/README.md +39 -0
  2. package/dist/adaptive-turn-materializer.d.ts +14 -0
  3. package/dist/adaptive-turn-materializer.d.ts.map +1 -0
  4. package/dist/adaptive-turn-materializer.js +56 -0
  5. package/dist/adaptive-turn-materializer.js.map +1 -0
  6. package/dist/adaptive-turn.d.ts +23 -0
  7. package/dist/adaptive-turn.d.ts.map +1 -0
  8. package/dist/adaptive-turn.js +60 -0
  9. package/dist/adaptive-turn.js.map +1 -0
  10. package/dist/asset-url.d.ts.map +1 -1
  11. package/dist/asset-url.js +1 -0
  12. package/dist/asset-url.js.map +1 -1
  13. package/dist/delivery-redaction.d.ts +50 -0
  14. package/dist/delivery-redaction.d.ts.map +1 -0
  15. package/dist/delivery-redaction.js +176 -0
  16. package/dist/delivery-redaction.js.map +1 -0
  17. package/dist/delivery-security.d.ts +3 -18
  18. package/dist/delivery-security.d.ts.map +1 -1
  19. package/dist/delivery-security.js +35 -189
  20. package/dist/delivery-security.js.map +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/server-scoring.d.ts +4 -14
  26. package/dist/server-scoring.d.ts.map +1 -1
  27. package/dist/server-scoring.js +19 -157
  28. package/dist/server-scoring.js.map +1 -1
  29. package/dist/shared-vocabulary-support.d.ts.map +1 -1
  30. package/dist/shared-vocabulary-support.js +1 -0
  31. package/dist/shared-vocabulary-support.js.map +1 -1
  32. package/dist/support.d.ts.map +1 -1
  33. package/dist/support.js +8 -4
  34. package/dist/support.js.map +1 -1
  35. package/dist/trusted-item-session.d.ts +49 -0
  36. package/dist/trusted-item-session.d.ts.map +1 -0
  37. package/dist/trusted-item-session.js +260 -0
  38. package/dist/trusted-item-session.js.map +1 -0
  39. package/package.json +4 -2
  40. package/src/adaptive-turn-materializer.ts +85 -0
  41. package/src/adaptive-turn.ts +109 -0
  42. package/src/asset-url.ts +1 -0
  43. package/src/delivery-redaction.ts +268 -0
  44. package/src/delivery-security.ts +59 -245
  45. package/src/index.ts +7 -0
  46. package/src/server-scoring.ts +30 -226
  47. package/src/shared-vocabulary-support.ts +1 -0
  48. package/src/support.ts +9 -4
  49. package/src/trusted-item-session.ts +427 -0
  50. package/src/interaction-test-fixtures.ts +0 -44
  51. package/src/serializer-processing.fixtures.ts +0 -469
@@ -0,0 +1,427 @@
1
+ import { parseQtiXml } from "./parser.js";
2
+ import { createItemSession, isQtiAttemptStateV1, type QtiItemSession } from "./session.js";
3
+ import type {
4
+ QtiAssessmentItem,
5
+ QtiAttemptStateV1,
6
+ QtiAttemptStatus,
7
+ QtiDiagnostic,
8
+ QtiDocument,
9
+ QtiPortableCustomStateValue,
10
+ QtiValue,
11
+ } from "./types.js";
12
+ import { isQtiPortableCustomStateValue, readQtiJsonValue } from "./value-format.js";
13
+
14
+ export type QtiTrustedInputDiagnosticPrefix = "serverScoring" | "adaptiveTurn";
15
+
16
+ export interface QtiTrustedResponseInput {
17
+ identifier: string;
18
+ value: unknown;
19
+ }
20
+
21
+ export type QtiTrustedResponsesInput =
22
+ | Record<string, unknown>
23
+ | readonly QtiTrustedResponseInput[]
24
+ | undefined;
25
+
26
+ /** Trusted candidate responses and portable custom interaction state for one application. */
27
+ export interface QtiTrustedResponseApplication {
28
+ trustedResponses?: QtiTrustedResponsesInput;
29
+ trustedInteractionStates?: Record<string, QtiPortableCustomStateValue> | undefined;
30
+ }
31
+
32
+ export interface QtiTrustedItemParseOptions {
33
+ allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
34
+ }
35
+
36
+ interface QtiParsedTrustedItem {
37
+ document: QtiDocument;
38
+ diagnostics: QtiDiagnostic[];
39
+ responseIdentifiers: Set<string>;
40
+ }
41
+
42
+ export type QtiTrustedItemScoringPolicy = "always" | "onSubmission";
43
+
44
+ export interface RunTrustedItemSessionInput extends QtiTrustedItemParseOptions {
45
+ itemXml: string;
46
+ diagnosticPrefix: QtiTrustedInputDiagnosticPrefix;
47
+ submission: QtiTrustedResponseApplication;
48
+ priorState?: QtiAttemptStateV1 | null | undefined;
49
+ /** Applied before submission. Intended for server-side scoring, not adaptive turns. */
50
+ attemptStatus?: QtiAttemptStatus | undefined;
51
+ scoring: QtiTrustedItemScoringPolicy;
52
+ requireNumericScore: boolean;
53
+ }
54
+
55
+ export interface RunTrustedItemSessionSuccess {
56
+ ok: true;
57
+ diagnostics: QtiDiagnostic[];
58
+ state: QtiAttemptStateV1;
59
+ responses: Record<string, QtiValue>;
60
+ outcomes: Record<string, QtiValue>;
61
+ score: number | null;
62
+ }
63
+
64
+ export interface RunTrustedItemSessionFailure {
65
+ ok: false;
66
+ diagnostics: QtiDiagnostic[];
67
+ state: QtiAttemptStateV1 | null;
68
+ responses: Record<string, QtiValue>;
69
+ outcomes: Record<string, QtiValue>;
70
+ score: number | null;
71
+ }
72
+
73
+ export type RunTrustedItemSessionResult =
74
+ | RunTrustedItemSessionSuccess
75
+ | RunTrustedItemSessionFailure;
76
+
77
+ export function readPriorAttemptState(
78
+ priorState: unknown,
79
+ diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
80
+ ): { state: QtiAttemptStateV1 | null; diagnostics: QtiDiagnostic[] } {
81
+ if (priorState === undefined || priorState === null) return { state: null, diagnostics: [] };
82
+ if (isQtiAttemptStateV1(priorState)) return { state: priorState, diagnostics: [] };
83
+ return {
84
+ state: null,
85
+ diagnostics: [
86
+ {
87
+ code: `${diagnosticPrefix}.state.value`,
88
+ severity: "error",
89
+ message: "Prior adaptive turn state is not a valid qti3.attempt-state.v1 value.",
90
+ },
91
+ ],
92
+ };
93
+ }
94
+
95
+ export function runTrustedItemSession(
96
+ input: RunTrustedItemSessionInput,
97
+ ): RunTrustedItemSessionResult {
98
+ const parsedResult = parseTrustedItemXml(
99
+ input.itemXml,
100
+ input.allowedUndeclaredResponseIdentifiers,
101
+ );
102
+ if (!parsedResult.ok) {
103
+ return emptyTrustedItemSessionFailure(parsedResult.diagnostics);
104
+ }
105
+
106
+ const sessionResult = createTrustedItemSession(
107
+ parsedResult.parsed,
108
+ input.priorState,
109
+ input.diagnosticPrefix,
110
+ parsedResult.parsed.diagnostics,
111
+ );
112
+ if (!sessionResult.ok) {
113
+ return emptyTrustedItemSessionFailure(sessionResult.diagnostics);
114
+ }
115
+
116
+ if (input.attemptStatus) sessionResult.session.setStatus(input.attemptStatus);
117
+
118
+ const applicationResult = applyTrustedResponseApplication(
119
+ sessionResult.session,
120
+ parsedResult.parsed,
121
+ input.submission,
122
+ input.allowedUndeclaredResponseIdentifiers,
123
+ input.diagnosticPrefix,
124
+ );
125
+ const diagnostics = applicationResult.diagnostics;
126
+ if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
127
+ return emptyTrustedItemSessionFailure(diagnostics);
128
+ }
129
+
130
+ const shouldScore =
131
+ input.scoring === "always" ||
132
+ (input.scoring === "onSubmission" && applicationResult.appliedSubmission);
133
+
134
+ let outcomes = sessionResult.session.serialize().outcomes;
135
+ let state = sessionResult.session.serialize();
136
+ let score: number | null = null;
137
+ let scoredDiagnostics = diagnostics;
138
+
139
+ if (shouldScore) {
140
+ const scoredResult = scoreTrustedItemSession(
141
+ sessionResult.session,
142
+ diagnostics,
143
+ input.diagnosticPrefix,
144
+ );
145
+ if (!scoredResult.ok) {
146
+ return emptyTrustedItemSessionFailure(scoredResult.diagnostics);
147
+ }
148
+
149
+ scoredDiagnostics = [...diagnostics, ...scoredResult.scored.diagnostics];
150
+ outcomes = scoredResult.scored.outcomes;
151
+ state = stripUndeclaredResponses(
152
+ scoredResult.scored.state,
153
+ parsedResult.parsed.responseIdentifiers,
154
+ );
155
+ score = readNumericScore(outcomes.SCORE);
156
+
157
+ if (scoredResult.scored.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
158
+ return emptyTrustedItemSessionFailure(scoredDiagnostics, {
159
+ state,
160
+ responses: state.responses,
161
+ outcomes,
162
+ score,
163
+ });
164
+ }
165
+
166
+ if (
167
+ input.requireNumericScore &&
168
+ shouldRequireNumericScore(parsedResult.parsed.document.item) &&
169
+ score === null
170
+ ) {
171
+ return emptyTrustedItemSessionFailure(
172
+ [...scoredDiagnostics, missingNumericScoreDiagnostic(input.diagnosticPrefix)],
173
+ {
174
+ state,
175
+ responses: state.responses,
176
+ outcomes,
177
+ score,
178
+ },
179
+ );
180
+ }
181
+ } else {
182
+ state = stripUndeclaredResponses(state, parsedResult.parsed.responseIdentifiers);
183
+ outcomes = state.outcomes;
184
+ score = readNumericScore(outcomes.SCORE);
185
+ }
186
+
187
+ return {
188
+ ok: true,
189
+ diagnostics: scoredDiagnostics,
190
+ state,
191
+ responses: state.responses,
192
+ outcomes,
193
+ score,
194
+ };
195
+ }
196
+
197
+ function emptyTrustedItemSessionFailure(
198
+ diagnostics: QtiDiagnostic[],
199
+ partial: Partial<
200
+ Pick<RunTrustedItemSessionFailure, "state" | "responses" | "outcomes" | "score">
201
+ > = {},
202
+ ): RunTrustedItemSessionFailure {
203
+ return {
204
+ ok: false,
205
+ diagnostics,
206
+ state: partial.state ?? null,
207
+ responses: partial.responses ?? partial.state?.responses ?? {},
208
+ outcomes: partial.outcomes ?? partial.state?.outcomes ?? {},
209
+ score: partial.score ?? null,
210
+ };
211
+ }
212
+
213
+ function parseTrustedItemXml(
214
+ itemXml: string,
215
+ allowedUndeclaredResponseIdentifiers: readonly string[] = [],
216
+ ): { ok: true; parsed: QtiParsedTrustedItem } | { ok: false; diagnostics: QtiDiagnostic[] } {
217
+ let parsed: ReturnType<typeof parseQtiXml>;
218
+ try {
219
+ parsed = parseQtiXml(itemXml);
220
+ } catch (error) {
221
+ return {
222
+ ok: false,
223
+ diagnostics: [
224
+ {
225
+ code: "xml.parse",
226
+ severity: "error",
227
+ message: error instanceof Error ? error.message : String(error),
228
+ },
229
+ ],
230
+ };
231
+ }
232
+
233
+ const allowedUndeclared = new Set(allowedUndeclaredResponseIdentifiers);
234
+ const diagnostics = parsed.diagnostics.filter(
235
+ (diagnostic) => !isAllowedUndeclaredVariableReference(diagnostic, allowedUndeclared),
236
+ );
237
+ if (!parsed.document || diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
238
+ return { ok: false, diagnostics };
239
+ }
240
+
241
+ return {
242
+ ok: true,
243
+ parsed: {
244
+ document: parsed.document,
245
+ diagnostics,
246
+ responseIdentifiers: new Set(
247
+ parsed.document.item.responseDeclarations.map((declaration) => declaration.identifier),
248
+ ),
249
+ },
250
+ };
251
+ }
252
+
253
+ function createTrustedItemSession(
254
+ parsed: QtiParsedTrustedItem,
255
+ priorState: QtiAttemptStateV1 | null | undefined,
256
+ diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
257
+ existingDiagnostics: QtiDiagnostic[] = [],
258
+ ): { ok: true; session: QtiItemSession } | { ok: false; diagnostics: QtiDiagnostic[] } {
259
+ try {
260
+ return {
261
+ ok: true,
262
+ session: createItemSession(parsed.document, priorState ?? undefined),
263
+ };
264
+ } catch (error) {
265
+ return {
266
+ ok: false,
267
+ diagnostics: [
268
+ ...existingDiagnostics,
269
+ {
270
+ code: `${diagnosticPrefix}.state.restore`,
271
+ severity: "error",
272
+ message: error instanceof Error ? error.message : String(error),
273
+ },
274
+ ],
275
+ };
276
+ }
277
+ }
278
+
279
+ function applyTrustedResponseApplication(
280
+ session: QtiItemSession,
281
+ parsed: QtiParsedTrustedItem,
282
+ submission: QtiTrustedResponseApplication,
283
+ allowedUndeclaredResponseIdentifiers: readonly string[] | undefined,
284
+ diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
285
+ ): { diagnostics: QtiDiagnostic[]; appliedSubmission: boolean } {
286
+ const diagnostics = [...parsed.diagnostics];
287
+ const allowedUndeclared = new Set(allowedUndeclaredResponseIdentifiers ?? []);
288
+ let appliedSubmission = false;
289
+
290
+ for (const response of normalizeResponseInputs(submission.trustedResponses)) {
291
+ const identifier = response.identifier.trim();
292
+ if (!identifier) {
293
+ diagnostics.push({
294
+ code: `${diagnosticPrefix}.response.identifier`,
295
+ severity: "error",
296
+ message: "Trusted response identifiers must be non-empty strings.",
297
+ });
298
+ continue;
299
+ }
300
+
301
+ if (!parsed.responseIdentifiers.has(identifier) && !allowedUndeclared.has(identifier)) {
302
+ diagnostics.push({
303
+ code: `${diagnosticPrefix}.response.ignored`,
304
+ severity: "warning",
305
+ message: `Trusted response ${identifier} was ignored because it is not declared by the item.`,
306
+ });
307
+ continue;
308
+ }
309
+
310
+ const value = readQtiJsonValue(response.value);
311
+ if (value === undefined) {
312
+ diagnostics.push({
313
+ code: `${diagnosticPrefix}.response.value`,
314
+ severity: "error",
315
+ message: `Trusted response ${identifier} is not a supported QTI value.`,
316
+ });
317
+ continue;
318
+ }
319
+ session.respond(identifier, value);
320
+ appliedSubmission = true;
321
+ }
322
+
323
+ for (const [identifier, state] of Object.entries(submission.trustedInteractionStates ?? {})) {
324
+ if (!isQtiPortableCustomStateValue(state)) {
325
+ diagnostics.push({
326
+ code: `${diagnosticPrefix}.interactionState.value`,
327
+ severity: "error",
328
+ message: `Trusted interaction state ${identifier} is not a supported portable custom state value.`,
329
+ });
330
+ continue;
331
+ }
332
+
333
+ try {
334
+ session.setInteractionState(identifier, state);
335
+ appliedSubmission = true;
336
+ } catch (error) {
337
+ diagnostics.push({
338
+ code: `${diagnosticPrefix}.interactionState.identifier`,
339
+ severity: "error",
340
+ message: error instanceof Error ? error.message : String(error),
341
+ });
342
+ }
343
+ }
344
+
345
+ return { diagnostics, appliedSubmission };
346
+ }
347
+
348
+ function scoreTrustedItemSession(
349
+ session: QtiItemSession,
350
+ diagnostics: QtiDiagnostic[],
351
+ diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
352
+ ):
353
+ | { ok: true; scored: ReturnType<QtiItemSession["score"]> }
354
+ | { ok: false; diagnostics: QtiDiagnostic[] } {
355
+ try {
356
+ return { ok: true, scored: session.score() };
357
+ } catch (error) {
358
+ return {
359
+ ok: false,
360
+ diagnostics: [
361
+ ...diagnostics,
362
+ {
363
+ code: `${diagnosticPrefix}.score.exception`,
364
+ severity: "error",
365
+ message: error instanceof Error ? error.message : String(error),
366
+ },
367
+ ],
368
+ };
369
+ }
370
+ }
371
+
372
+ function shouldRequireNumericScore(item: QtiAssessmentItem): boolean {
373
+ return (
374
+ Boolean(item.responseProcessing) ||
375
+ item.outcomeDeclarations.some((declaration) => declaration.identifier === "SCORE")
376
+ );
377
+ }
378
+
379
+ function readNumericScore(value: QtiValue | undefined): number | null {
380
+ if (typeof value === "number" && Number.isFinite(value)) return value;
381
+ if (typeof value === "string") {
382
+ const score = Number(value.trim());
383
+ return Number.isFinite(score) ? score : null;
384
+ }
385
+ return null;
386
+ }
387
+
388
+ function missingNumericScoreDiagnostic(
389
+ diagnosticPrefix: QtiTrustedInputDiagnosticPrefix,
390
+ ): QtiDiagnostic {
391
+ return {
392
+ code: `${diagnosticPrefix}.score.missing`,
393
+ severity: "error",
394
+ message: "Server-side scoring did not produce a numeric SCORE outcome.",
395
+ };
396
+ }
397
+
398
+ function stripUndeclaredResponses(
399
+ state: QtiAttemptStateV1,
400
+ declaredResponseIdentifiers: Set<string>,
401
+ ): QtiAttemptStateV1 {
402
+ return {
403
+ ...state,
404
+ responses: Object.fromEntries(
405
+ Object.entries(state.responses).filter(([identifier]) =>
406
+ declaredResponseIdentifiers.has(identifier),
407
+ ),
408
+ ),
409
+ };
410
+ }
411
+
412
+ function normalizeResponseInputs(responses: QtiTrustedResponsesInput): QtiTrustedResponseInput[] {
413
+ if (!responses) return [];
414
+ if (Array.isArray(responses)) return [...responses];
415
+ return Object.entries(responses).map(([identifier, value]) => ({ identifier, value }));
416
+ }
417
+
418
+ function isAllowedUndeclaredVariableReference(
419
+ diagnostic: QtiDiagnostic,
420
+ allowedUndeclaredResponseIdentifiers: Set<string>,
421
+ ): boolean {
422
+ if (diagnostic.code !== "processing.variable.reference") return false;
423
+ const identifier = diagnostic.message.match(
424
+ /^Processing expression references missing variable (.+)\.$/,
425
+ )?.[1];
426
+ return Boolean(identifier && allowedUndeclaredResponseIdentifiers.has(identifier));
427
+ }
@@ -1,44 +0,0 @@
1
- import {
2
- deprecatedInteractionSupport,
3
- interactionRegistryStatus,
4
- interactionSupport,
5
- } from "./support.js";
6
- import type { QtiInteraction, QtiSourceLocation } from "./types.js";
7
-
8
- const defaultSource: QtiSourceLocation = {
9
- line: 1,
10
- column: 1,
11
- offset: 0,
12
- path: "item",
13
- };
14
-
15
- const qtiNameByInteractionType = new Map(
16
- [...interactionSupport, ...deprecatedInteractionSupport].map((entry) => [
17
- entry.interactionType,
18
- entry.qtiName,
19
- ]),
20
- );
21
-
22
- export function testInteraction(
23
- overrides: Partial<QtiInteraction> & Pick<QtiInteraction, "type">,
24
- ): QtiInteraction {
25
- const qtiName =
26
- overrides.qtiName ?? qtiNameByInteractionType.get(overrides.type) ?? `qti-${overrides.type}`;
27
- const interaction = {
28
- registryStatus: interactionRegistryStatus(qtiName),
29
- qtiName,
30
- responseIdentifier: "RESPONSE",
31
- responseCardinality: "single" as const,
32
- responseBaseType: "identifier" as const,
33
- choices: [],
34
- attributes: {},
35
- childElements: [],
36
- text: "",
37
- source: defaultSource,
38
- ...overrides,
39
- };
40
- return {
41
- ...interaction,
42
- registryStatus: overrides.registryStatus ?? interactionRegistryStatus(interaction.qtiName),
43
- };
44
- }