@longsightgroup/qti3-core 0.1.2 → 0.2.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/src/tts.ts ADDED
@@ -0,0 +1,555 @@
1
+ import type {
2
+ QtiAssessmentItem,
3
+ QtiChoice,
4
+ QtiContentNode,
5
+ QtiDiagnostic,
6
+ QtiDocument,
7
+ QtiInteraction,
8
+ QtiSourceLocation,
9
+ } from "./types.js";
10
+
11
+ export type QtiDataSsmlBreakStrength =
12
+ | "medium"
13
+ | "none"
14
+ | "strong"
15
+ | "weak"
16
+ | "x-strong"
17
+ | "x-weak";
18
+
19
+ export interface QtiDataSsmlBreak {
20
+ strength?: QtiDataSsmlBreakStrength | undefined;
21
+ time?: string | undefined;
22
+ }
23
+
24
+ export interface QtiDataSsmlPhoneme {
25
+ ph: string;
26
+ alphabet?: "ipa" | "x-sampa" | undefined;
27
+ type?: "default" | "ruby" | undefined;
28
+ }
29
+
30
+ export interface QtiDataSsmlProsody {
31
+ rate?: string | undefined;
32
+ }
33
+
34
+ export interface QtiDataSsmlSayAs {
35
+ "interpret-as": "cardinal" | "characters" | "date" | "ordinal" | "telephone" | "time";
36
+ }
37
+
38
+ export interface QtiDataSsmlSub {
39
+ alias: string;
40
+ }
41
+
42
+ export interface QtiDataSsml {
43
+ break?: QtiDataSsmlBreak | undefined;
44
+ phoneme?: QtiDataSsmlPhoneme | undefined;
45
+ prosody?: QtiDataSsmlProsody | undefined;
46
+ "say-as"?: QtiDataSsmlSayAs | undefined;
47
+ sub?: QtiDataSsmlSub | undefined;
48
+ }
49
+
50
+ export type QtiDataSsmlParseResult =
51
+ | { ok: true; value: QtiDataSsml }
52
+ | { ok: false; errors: string[] };
53
+
54
+ export type QtiTextToSpeechSegmentKind =
55
+ | "text"
56
+ | "content"
57
+ | "interaction"
58
+ | "interactionPrompt"
59
+ | "choice"
60
+ | "printedVariable"
61
+ | "feedback";
62
+
63
+ export interface QtiTextToSpeechSegment {
64
+ index: number;
65
+ kind: QtiTextToSpeechSegmentKind;
66
+ text: string;
67
+ attributes: Record<string, string>;
68
+ qtiName?: string | undefined;
69
+ responseIdentifier?: string | undefined;
70
+ identifier?: string | undefined;
71
+ interactionIndex?: number | undefined;
72
+ choiceIdentifier?: string | undefined;
73
+ dataSsml?: string | undefined;
74
+ ssml?: QtiDataSsml | undefined;
75
+ ssmlErrors?: string[] | undefined;
76
+ suppressTts?: string[] | undefined;
77
+ source?: QtiSourceLocation | undefined;
78
+ }
79
+
80
+ export interface QtiTextToSpeechTraversal {
81
+ itemIdentifier: string;
82
+ language?: string | undefined;
83
+ segments: QtiTextToSpeechSegment[];
84
+ diagnostics: QtiDiagnostic[];
85
+ }
86
+
87
+ interface TraversalContext {
88
+ item: QtiAssessmentItem;
89
+ segments: QtiTextToSpeechSegment[];
90
+ diagnostics: QtiDiagnostic[];
91
+ }
92
+
93
+ interface SegmentInput {
94
+ kind: QtiTextToSpeechSegmentKind;
95
+ text: string;
96
+ attributes?: Record<string, string> | undefined;
97
+ qtiName?: string | undefined;
98
+ responseIdentifier?: string | undefined;
99
+ identifier?: string | undefined;
100
+ interactionIndex?: number | undefined;
101
+ choiceIdentifier?: string | undefined;
102
+ source?: QtiSourceLocation | undefined;
103
+ }
104
+
105
+ const dataSsmlFunctionNames = new Set(["break", "phoneme", "prosody", "say-as", "sub"]);
106
+ const breakStrengths = new Set(["medium", "none", "strong", "weak", "x-strong", "x-weak"]);
107
+ const phonemeAlphabets = new Set(["ipa", "x-sampa"]);
108
+ const phonemeTypes = new Set(["default", "ruby"]);
109
+ const sayAsInterpretations = new Set([
110
+ "cardinal",
111
+ "characters",
112
+ "date",
113
+ "ordinal",
114
+ "telephone",
115
+ "time",
116
+ ]);
117
+
118
+ export function parseQtiDataSsml(raw: string): QtiDataSsmlParseResult {
119
+ let parsed: unknown;
120
+ try {
121
+ parsed = JSON.parse(raw);
122
+ } catch (error) {
123
+ const message = error instanceof Error ? error.message : "Unable to parse JSON.";
124
+ return { ok: false, errors: [`data-ssml must be valid JSON: ${message}`] };
125
+ }
126
+
127
+ if (!isRecord(parsed)) {
128
+ return { ok: false, errors: ["data-ssml must be a JSON object."] };
129
+ }
130
+
131
+ const errors: string[] = [];
132
+ const value: QtiDataSsml = {};
133
+ const entries = Object.entries(parsed);
134
+ if (entries.length === 0) {
135
+ errors.push("data-ssml must include at least one Data-SSML function.");
136
+ }
137
+
138
+ for (const [name, functionValue] of entries) {
139
+ if (!dataSsmlFunctionNames.has(name)) {
140
+ errors.push(`Unsupported Data-SSML function "${name}".`);
141
+ continue;
142
+ }
143
+ if (!isRecord(functionValue)) {
144
+ errors.push(`Data-SSML function "${name}" must be a JSON object.`);
145
+ continue;
146
+ }
147
+
148
+ const before = errors.length;
149
+ if (name === "break") {
150
+ const breakValue = validateBreak(functionValue, errors);
151
+ if (errors.length === before) value["break"] = breakValue;
152
+ } else if (name === "phoneme") {
153
+ const phoneme = validatePhoneme(functionValue, errors);
154
+ if (phoneme && errors.length === before) value.phoneme = phoneme;
155
+ } else if (name === "prosody") {
156
+ const prosody = validateProsody(functionValue, errors);
157
+ if (errors.length === before) value.prosody = prosody;
158
+ } else if (name === "say-as") {
159
+ const sayAs = validateSayAs(functionValue, errors);
160
+ if (sayAs && errors.length === before) value["say-as"] = sayAs;
161
+ } else if (name === "sub") {
162
+ const sub = validateSub(functionValue, errors);
163
+ if (sub && errors.length === before) value.sub = sub;
164
+ }
165
+ }
166
+
167
+ return errors.length > 0 ? { ok: false, errors } : { ok: true, value };
168
+ }
169
+
170
+ export function createTextToSpeechTraversal(
171
+ model: QtiDocument | QtiAssessmentItem,
172
+ ): QtiTextToSpeechTraversal {
173
+ const item = "item" in model ? model.item : model;
174
+ const context: TraversalContext = {
175
+ item,
176
+ segments: [],
177
+ diagnostics: [],
178
+ };
179
+ if (item.body.length > 0) {
180
+ traverseContentNodes(item.body, context);
181
+ } else {
182
+ item.interactions.forEach((interaction, index) =>
183
+ traverseInteraction(interaction, index, context),
184
+ );
185
+ }
186
+ return {
187
+ itemIdentifier: item.identifier,
188
+ language: item.language,
189
+ segments: context.segments,
190
+ diagnostics: context.diagnostics,
191
+ };
192
+ }
193
+
194
+ export function validateQtiDataSsmlMetadata(item: QtiAssessmentItem): QtiDiagnostic[] {
195
+ return createTextToSpeechTraversal(item).diagnostics;
196
+ }
197
+
198
+ function validateBreak(value: Record<string, unknown>, errors: string[]): QtiDataSsmlBreak {
199
+ validateAllowedProperties(value, "break", ["strength", "time"], errors);
200
+ const result: QtiDataSsmlBreak = {};
201
+ const strength = optionalEnum(value, "strength", "break.strength", breakStrengths, errors);
202
+ const time = optionalString(value, "time", "break.time", errors);
203
+ if (strength) result.strength = strength as QtiDataSsmlBreakStrength;
204
+ if (time !== undefined) result.time = time;
205
+ return result;
206
+ }
207
+
208
+ function validatePhoneme(
209
+ value: Record<string, unknown>,
210
+ errors: string[],
211
+ ): QtiDataSsmlPhoneme | undefined {
212
+ validateAllowedProperties(value, "phoneme", ["alphabet", "ph", "type"], errors);
213
+ const ph = requiredString(value, "ph", "phoneme.ph", errors);
214
+ const alphabet = optionalEnum(value, "alphabet", "phoneme.alphabet", phonemeAlphabets, errors);
215
+ const type = optionalEnum(value, "type", "phoneme.type", phonemeTypes, errors);
216
+ if (!ph) return undefined;
217
+ const result: QtiDataSsmlPhoneme = { ph };
218
+ if (alphabet) result.alphabet = alphabet as QtiDataSsmlPhoneme["alphabet"];
219
+ if (type) result.type = type as QtiDataSsmlPhoneme["type"];
220
+ return result;
221
+ }
222
+
223
+ function validateProsody(value: Record<string, unknown>, errors: string[]): QtiDataSsmlProsody {
224
+ validateAllowedProperties(value, "prosody", ["rate"], errors);
225
+ const result: QtiDataSsmlProsody = {};
226
+ const rate = optionalString(value, "rate", "prosody.rate", errors);
227
+ if (rate !== undefined) result.rate = rate;
228
+ return result;
229
+ }
230
+
231
+ function validateSayAs(
232
+ value: Record<string, unknown>,
233
+ errors: string[],
234
+ ): QtiDataSsmlSayAs | undefined {
235
+ validateAllowedProperties(value, "say-as", ["interpret-as"], errors);
236
+ const interpretation = requiredEnum(
237
+ value,
238
+ "interpret-as",
239
+ "say-as.interpret-as",
240
+ sayAsInterpretations,
241
+ errors,
242
+ );
243
+ if (!interpretation) return undefined;
244
+ return { "interpret-as": interpretation as QtiDataSsmlSayAs["interpret-as"] };
245
+ }
246
+
247
+ function validateSub(value: Record<string, unknown>, errors: string[]): QtiDataSsmlSub | undefined {
248
+ validateAllowedProperties(value, "sub", ["alias"], errors);
249
+ const alias = requiredString(value, "alias", "sub.alias", errors);
250
+ return alias ? { alias } : undefined;
251
+ }
252
+
253
+ function traverseContentNodes(nodes: QtiContentNode[], context: TraversalContext): void {
254
+ for (const node of nodes) {
255
+ traverseContentNode(node, context);
256
+ }
257
+ }
258
+
259
+ function traverseContentNode(node: QtiContentNode, context: TraversalContext): void {
260
+ if (node.kind === "text") {
261
+ addTextSegment(node.text, node.source, context);
262
+ return;
263
+ }
264
+
265
+ if (node.kind === "interaction") {
266
+ const interaction = context.item.interactions[node.interactionIndex];
267
+ if (interaction) traverseInteraction(interaction, node.interactionIndex, context);
268
+ return;
269
+ }
270
+
271
+ if (node.kind === "printedVariable") {
272
+ addSegment(
273
+ {
274
+ kind: "printedVariable",
275
+ text: "",
276
+ attributes: node.attributes,
277
+ qtiName: "qti-printed-variable",
278
+ identifier: node.identifier,
279
+ source: node.source,
280
+ },
281
+ context,
282
+ );
283
+ return;
284
+ }
285
+
286
+ if (node.kind === "feedback") {
287
+ if (hasTtsMetadata(node.attributes)) {
288
+ addSegment(
289
+ {
290
+ kind: "feedback",
291
+ text: normalizeSpeechText(contentNodeChildrenText(node.children)),
292
+ attributes: node.attributes,
293
+ qtiName: node.feedbackType === "block" ? "qti-feedback-block" : "qti-feedback-inline",
294
+ identifier: node.identifier,
295
+ source: node.source,
296
+ },
297
+ context,
298
+ );
299
+ return;
300
+ }
301
+ traverseContentNodes(node.children, context);
302
+ return;
303
+ }
304
+
305
+ if (hasTtsMetadata(node.attributes)) {
306
+ addSegment(
307
+ {
308
+ kind: "content",
309
+ text: normalizeSpeechText(contentNodeChildrenText(node.children)),
310
+ attributes: node.attributes,
311
+ qtiName: node.qtiName,
312
+ source: node.source,
313
+ },
314
+ context,
315
+ );
316
+ return;
317
+ }
318
+
319
+ traverseContentNodes(node.children, context);
320
+ }
321
+
322
+ function traverseInteraction(
323
+ interaction: QtiInteraction,
324
+ interactionIndex: number,
325
+ context: TraversalContext,
326
+ ): void {
327
+ if (hasTtsMetadata(interaction.attributes)) {
328
+ addSegment(
329
+ {
330
+ kind: "interaction",
331
+ text: normalizeSpeechText(interaction.prompt ?? interaction.text),
332
+ attributes: interaction.attributes,
333
+ qtiName: interaction.qtiName,
334
+ responseIdentifier: interaction.responseIdentifier,
335
+ interactionIndex,
336
+ source: interaction.source,
337
+ },
338
+ context,
339
+ );
340
+ }
341
+
342
+ const promptText = normalizeSpeechText(interaction.prompt ?? "");
343
+ const promptAttributes = interaction.promptAttributes ?? {};
344
+ if (promptText.length > 0 || hasTtsMetadata(promptAttributes)) {
345
+ addSegment(
346
+ {
347
+ kind: "interactionPrompt",
348
+ text: promptText,
349
+ attributes: promptAttributes,
350
+ qtiName: "qti-prompt",
351
+ responseIdentifier: interaction.responseIdentifier,
352
+ interactionIndex,
353
+ source: interaction.promptSource ?? interaction.source,
354
+ },
355
+ context,
356
+ );
357
+ }
358
+
359
+ for (const choice of interaction.choices) {
360
+ addChoiceSegment(choice, interaction, interactionIndex, context);
361
+ }
362
+
363
+ if (interaction.portableCustom) {
364
+ traverseContentNodes(interaction.portableCustom.interactionMarkup, context);
365
+ }
366
+ }
367
+
368
+ function addChoiceSegment(
369
+ choice: QtiChoice,
370
+ interaction: QtiInteraction,
371
+ interactionIndex: number,
372
+ context: TraversalContext,
373
+ ): void {
374
+ const text = normalizeSpeechText(choice.text);
375
+ if (text.length === 0 && !hasTtsMetadata(choice.attributes)) return;
376
+ addSegment(
377
+ {
378
+ kind: "choice",
379
+ text,
380
+ attributes: choice.attributes,
381
+ qtiName: choice.qtiName,
382
+ responseIdentifier: interaction.responseIdentifier,
383
+ identifier: choice.identifier,
384
+ interactionIndex,
385
+ choiceIdentifier: choice.identifier,
386
+ source: choice.source,
387
+ },
388
+ context,
389
+ );
390
+ }
391
+
392
+ function addTextSegment(
393
+ text: string,
394
+ source: QtiSourceLocation | undefined,
395
+ context: TraversalContext,
396
+ ): void {
397
+ const normalized = normalizeSpeechText(text);
398
+ if (normalized.length === 0) return;
399
+ addSegment({ kind: "text", text: normalized, attributes: {}, source }, context);
400
+ }
401
+
402
+ function addSegment(input: SegmentInput, context: TraversalContext): void {
403
+ const attributes = input.attributes ?? {};
404
+ const segment: QtiTextToSpeechSegment = {
405
+ index: context.segments.length,
406
+ kind: input.kind,
407
+ text: input.text,
408
+ attributes: { ...attributes },
409
+ };
410
+ if (input.qtiName) segment.qtiName = input.qtiName;
411
+ if (input.responseIdentifier) segment.responseIdentifier = input.responseIdentifier;
412
+ if (input.identifier) segment.identifier = input.identifier;
413
+ if (input.interactionIndex !== undefined) segment.interactionIndex = input.interactionIndex;
414
+ if (input.choiceIdentifier) segment.choiceIdentifier = input.choiceIdentifier;
415
+ if (input.source) segment.source = input.source;
416
+
417
+ const suppressTts = ttsSuppressionModes(attributes);
418
+ if (suppressTts.length > 0) segment.suppressTts = suppressTts;
419
+
420
+ const rawDataSsml = attributeValue(attributes, "data-ssml");
421
+ if (rawDataSsml !== undefined) {
422
+ segment.dataSsml = rawDataSsml;
423
+ const parsed = parseQtiDataSsml(rawDataSsml);
424
+ if (parsed.ok) {
425
+ segment.ssml = parsed.value;
426
+ } else {
427
+ segment.ssmlErrors = parsed.errors;
428
+ context.diagnostics.push({
429
+ code: "content.dataSsml.invalid",
430
+ severity: "warning",
431
+ message: `data-ssml must be valid Data-SSML JSON: ${parsed.errors.join("; ")}`,
432
+ path: input.source?.path,
433
+ source: input.source,
434
+ });
435
+ }
436
+ }
437
+
438
+ context.segments.push(segment);
439
+ }
440
+
441
+ function hasTtsMetadata(attributes: Record<string, string>): boolean {
442
+ return (
443
+ attributeValue(attributes, "data-ssml") !== undefined ||
444
+ attributeValue(attributes, "data-qti-suppress-tts") !== undefined
445
+ );
446
+ }
447
+
448
+ function ttsSuppressionModes(attributes: Record<string, string>): string[] {
449
+ const raw = attributeValue(attributes, "data-qti-suppress-tts");
450
+ if (!raw) return [];
451
+ return raw
452
+ .toLowerCase()
453
+ .split(/[\s,]+/)
454
+ .filter(Boolean);
455
+ }
456
+
457
+ function contentNodeChildrenText(nodes: QtiContentNode[]): string {
458
+ return nodes.map(contentNodeText).join(" ");
459
+ }
460
+
461
+ function contentNodeText(node: QtiContentNode): string {
462
+ if (node.kind === "text") return node.text;
463
+ if (node.kind === "interaction") return "";
464
+ if (node.kind === "printedVariable") return "";
465
+ if (node.kind === "feedback") return contentNodeChildrenText(node.children);
466
+ return contentNodeChildrenText(node.children);
467
+ }
468
+
469
+ function normalizeSpeechText(value: string): string {
470
+ return value.replace(/\s+/g, " ").trim();
471
+ }
472
+
473
+ function attributeValue(attributes: Record<string, string>, name: string): string | undefined {
474
+ const normalizedName = name.toLowerCase();
475
+ const entry = Object.entries(attributes).find(
476
+ ([attributeName]) => attributeName.toLowerCase() === normalizedName,
477
+ );
478
+ return entry?.[1];
479
+ }
480
+
481
+ function validateAllowedProperties(
482
+ value: Record<string, unknown>,
483
+ path: string,
484
+ allowed: string[],
485
+ errors: string[],
486
+ ): void {
487
+ const allowedNames = new Set(allowed);
488
+ for (const name of Object.keys(value)) {
489
+ if (!allowedNames.has(name)) {
490
+ errors.push(`${path}.${name} is not a supported Data-SSML property.`);
491
+ }
492
+ }
493
+ }
494
+
495
+ function optionalString(
496
+ value: Record<string, unknown>,
497
+ name: string,
498
+ path: string,
499
+ errors: string[],
500
+ ): string | undefined {
501
+ if (!Object.hasOwn(value, name)) return undefined;
502
+ const property = value[name];
503
+ if (typeof property !== "string") {
504
+ errors.push(`${path} must be a string.`);
505
+ return undefined;
506
+ }
507
+ return property;
508
+ }
509
+
510
+ function requiredString(
511
+ value: Record<string, unknown>,
512
+ name: string,
513
+ path: string,
514
+ errors: string[],
515
+ ): string | undefined {
516
+ if (!Object.hasOwn(value, name)) {
517
+ errors.push(`${path} is required.`);
518
+ return undefined;
519
+ }
520
+ return optionalString(value, name, path, errors);
521
+ }
522
+
523
+ function optionalEnum(
524
+ value: Record<string, unknown>,
525
+ name: string,
526
+ path: string,
527
+ allowed: Set<string>,
528
+ errors: string[],
529
+ ): string | undefined {
530
+ const property = optionalString(value, name, path, errors);
531
+ if (property === undefined) return undefined;
532
+ if (!allowed.has(property)) {
533
+ errors.push(`${path} has unsupported value "${property}".`);
534
+ return undefined;
535
+ }
536
+ return property;
537
+ }
538
+
539
+ function requiredEnum(
540
+ value: Record<string, unknown>,
541
+ name: string,
542
+ path: string,
543
+ allowed: Set<string>,
544
+ errors: string[],
545
+ ): string | undefined {
546
+ if (!Object.hasOwn(value, name)) {
547
+ errors.push(`${path} is required.`);
548
+ return undefined;
549
+ }
550
+ return optionalEnum(value, name, path, allowed, errors);
551
+ }
552
+
553
+ function isRecord(value: unknown): value is Record<string, unknown> {
554
+ return typeof value === "object" && value !== null && !Array.isArray(value);
555
+ }
package/src/types.ts CHANGED
@@ -47,6 +47,13 @@ export interface QtiRecordValue {
47
47
  [fieldIdentifier: string]: QtiValue;
48
48
  }
49
49
  export type QtiValue = QtiScalarValue | QtiScalarValue[] | QtiRecordValue | null;
50
+ export type QtiPortableCustomStateValue =
51
+ | string
52
+ | number
53
+ | boolean
54
+ | null
55
+ | QtiPortableCustomStateValue[]
56
+ | { [key: string]: QtiPortableCustomStateValue };
50
57
 
51
58
  export type QtiAttemptStatus = "initialized" | "interacting" | "suspended" | "completed";
52
59
 
@@ -185,9 +192,12 @@ export interface QtiInteraction {
185
192
  responseCardinality?: QtiCardinality | undefined;
186
193
  responseBaseType?: QtiBaseType | undefined;
187
194
  prompt?: string | undefined;
195
+ promptAttributes?: Record<string, string> | undefined;
196
+ promptSource?: QtiSourceLocation | undefined;
188
197
  contextText?: string | undefined;
189
198
  object?: QtiObjectAsset | undefined;
190
199
  positionObjectStage?: QtiObjectAsset | undefined;
200
+ portableCustom?: QtiPortableCustomDefinition | undefined;
191
201
  choices: QtiChoice[];
192
202
  hottextSegments?: QtiHottextSegment[] | undefined;
193
203
  gapMatchSegments?: QtiGapMatchSegment[] | undefined;
@@ -255,6 +265,46 @@ export interface QtiMediaTrack {
255
265
  source?: QtiSourceLocation | undefined;
256
266
  }
257
267
 
268
+ export interface QtiPortableCustomDefinition {
269
+ responseIdentifier?: string | undefined;
270
+ customInteractionTypeIdentifier?: string | undefined;
271
+ module?: string | undefined;
272
+ interactionModules?: QtiPortableCustomInteractionModules | undefined;
273
+ interactionMarkup: QtiContentNode[];
274
+ interactionMarkupRaw?: string | undefined;
275
+ templateVariables: QtiPortableCustomVariableBinding[];
276
+ contextVariables: QtiPortableCustomVariableBinding[];
277
+ stylesheets: QtiStylesheet[];
278
+ catalogInfo?: QtiCatalogInfo | undefined;
279
+ dataAttributes: Record<string, string>;
280
+ attributes: Record<string, string>;
281
+ source?: QtiSourceLocation | undefined;
282
+ }
283
+
284
+ export interface QtiPortableCustomInteractionModules {
285
+ primaryConfiguration?: string | undefined;
286
+ secondaryConfiguration?: string | undefined;
287
+ modules: QtiPortableCustomInteractionModule[];
288
+ attributes: Record<string, string>;
289
+ source?: QtiSourceLocation | undefined;
290
+ }
291
+
292
+ export interface QtiPortableCustomInteractionModule {
293
+ id?: string | undefined;
294
+ primaryPath?: string | undefined;
295
+ fallbackPath?: string | undefined;
296
+ attributes: Record<string, string>;
297
+ source?: QtiSourceLocation | undefined;
298
+ }
299
+
300
+ export interface QtiPortableCustomVariableBinding {
301
+ identifier?: string | undefined;
302
+ variableIdentifier?: string | undefined;
303
+ kind: "template" | "context";
304
+ attributes: Record<string, string>;
305
+ source?: QtiSourceLocation | undefined;
306
+ }
307
+
258
308
  export type QtiContentNode =
259
309
  | QtiTextContent
260
310
  | QtiElementContent
@@ -364,6 +414,7 @@ export interface QtiStylesheet {
364
414
  export interface QtiAssessmentItem {
365
415
  identifier: string;
366
416
  title?: string | undefined;
417
+ language?: string | undefined;
367
418
  adaptive: boolean;
368
419
  prompt?: string | undefined;
369
420
  responseDeclarations: QtiResponseDeclaration[];
@@ -617,6 +668,7 @@ export interface QtiAttemptStateV1 {
617
668
  responses: Record<string, QtiValue>;
618
669
  outcomes: Record<string, QtiValue>;
619
670
  templateValues?: Record<string, QtiValue> | undefined;
671
+ interactionStates?: Record<string, QtiPortableCustomStateValue> | undefined;
620
672
  validationMessages: QtiDiagnostic[];
621
673
  }
622
674