@longsightgroup/qti3-core 0.2.1 → 0.4.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/parser.ts CHANGED
@@ -44,6 +44,7 @@ import type {
44
44
  import { validateAssessmentItem } from "./validation.js";
45
45
  import { childElements, descendants, parseXmlTree, textContent, type XmlNode } from "./xml.js";
46
46
 
47
+ const qtiAssessmentItemNamespace = "http://www.imsglobal.org/xsd/imsqtiasi_v3p0";
47
48
  const supportedProcessingNames = new Set(processingSupport.map((entry) => entry.qtiName));
48
49
  const processingContainerNames = new Set(["qti-template-processing", "qti-response-processing"]);
49
50
  const responseProcessingForbiddenNames = new Set([
@@ -69,6 +70,10 @@ export function parseQtiXml(xml: string): QtiParseResult {
69
70
  });
70
71
  }
71
72
 
73
+ if (tree.errors.length > 0) {
74
+ return { ok: false, diagnostics };
75
+ }
76
+
72
77
  if (!tree.root) {
73
78
  diagnostics.push({
74
79
  code: "xml.empty",
@@ -78,12 +83,18 @@ export function parseQtiXml(xml: string): QtiParseResult {
78
83
  return { ok: false, diagnostics };
79
84
  }
80
85
 
81
- const itemNode = tree.root.localName === "qti-assessment-item" ? tree.root : undefined;
86
+ const itemNode =
87
+ tree.root.localName === "qti-assessment-item" && tree.root.uri === qtiAssessmentItemNamespace
88
+ ? tree.root
89
+ : undefined;
82
90
  if (!itemNode) {
83
91
  diagnostics.push({
84
92
  code: "qti.root",
85
93
  severity: "error",
86
- message: `Expected qti-assessment-item root, found ${tree.root.localName}.`,
94
+ message:
95
+ tree.root.localName === "qti-assessment-item"
96
+ ? `Expected qti-assessment-item in namespace ${qtiAssessmentItemNamespace}, found ${tree.root.uri ?? "(none)"}.`
97
+ : `Expected qti-assessment-item root, found ${tree.root.localName}.`,
87
98
  path: tree.root.source.path,
88
99
  source: tree.root.source,
89
100
  });
@@ -145,8 +156,11 @@ function parseAssessmentItem(node: XmlNode, diagnostics: QtiDiagnostic[]): QtiAs
145
156
  identifier,
146
157
  title: node.attributes.title,
147
158
  language: node.attributes["xml:lang"] ?? node.attributes.lang,
148
- adaptive: node.attributes.adaptive === "true",
159
+ adaptive: parseXmlBoolean(node.attributes.adaptive) === true,
160
+ timeDependent: parseXmlBoolean(node.attributes["time-dependent"]),
161
+ attributes: node.attributes,
149
162
  prompt: prompt ? textContent(prompt) : undefined,
163
+ itemBodySource: itemBody?.source,
150
164
  responseDeclarations,
151
165
  outcomeDeclarations,
152
166
  templateDeclarations,
@@ -1842,6 +1856,12 @@ function parseCardinality(value: string | undefined): QtiCardinality {
1842
1856
  return "single";
1843
1857
  }
1844
1858
 
1859
+ function parseXmlBoolean(value: string | undefined): boolean | undefined {
1860
+ if (value === "true" || value === "1") return true;
1861
+ if (value === "false" || value === "0") return false;
1862
+ return undefined;
1863
+ }
1864
+
1845
1865
  function normalizeValueForCardinality(value: QtiValue, cardinality: QtiCardinality): QtiValue {
1846
1866
  if (
1847
1867
  (cardinality === "multiple" || cardinality === "ordered") &&
@@ -0,0 +1,244 @@
1
+ import { createItemSession } from "./session.js";
2
+ import { parseQtiXml } from "./parser.js";
3
+ import type {
4
+ QtiAssessmentItem,
5
+ QtiAttemptStateV1,
6
+ QtiAttemptStatus,
7
+ QtiDiagnostic,
8
+ QtiPortableCustomStateValue,
9
+ QtiScoreResult,
10
+ QtiValue,
11
+ } from "./types.js";
12
+ import { isQtiPortableCustomStateValue, readQtiJsonValue } from "./value-format.js";
13
+
14
+ export interface QtiServerScoringResponseInput {
15
+ identifier: string;
16
+ value: unknown;
17
+ }
18
+
19
+ export interface QtiServerScoringInput {
20
+ itemXml: string;
21
+ /**
22
+ * Server-trusted response values. This API validates JSON/QTI value shape and declared
23
+ * identifiers, but it does not run candidate response-validation policy such as required
24
+ * interactions or min/max response counts.
25
+ */
26
+ trustedResponses?: Record<string, unknown> | readonly QtiServerScoringResponseInput[] | undefined;
27
+ trustedInteractionStates?: Record<string, QtiPortableCustomStateValue> | undefined;
28
+ status?: QtiAttemptStatus | undefined;
29
+ allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
30
+ }
31
+
32
+ export interface QtiServerScoringResult {
33
+ ok: boolean;
34
+ diagnostics: QtiDiagnostic[];
35
+ state: QtiAttemptStateV1 | null;
36
+ responses: Record<string, QtiValue>;
37
+ outcomes: Record<string, QtiValue>;
38
+ score: number | null;
39
+ }
40
+
41
+ export function scoreQtiItemServerSide(input: QtiServerScoringInput): QtiServerScoringResult {
42
+ let parsed: ReturnType<typeof parseQtiXml>;
43
+ try {
44
+ parsed = parseQtiXml(input.itemXml);
45
+ } catch (error) {
46
+ return failed([
47
+ {
48
+ code: "xml.parse",
49
+ severity: "error",
50
+ message: error instanceof Error ? error.message : String(error),
51
+ },
52
+ ]);
53
+ }
54
+
55
+ const allowedUndeclaredResponseIdentifiers = new Set(
56
+ input.allowedUndeclaredResponseIdentifiers ?? [],
57
+ );
58
+ const parseDiagnostics = parsed.diagnostics.filter(
59
+ (diagnostic) =>
60
+ !isAllowedUndeclaredVariableReference(diagnostic, allowedUndeclaredResponseIdentifiers),
61
+ );
62
+ if (!parsed.document || parseDiagnostics.some((diagnostic) => diagnostic.severity === "error")) {
63
+ return failed(parseDiagnostics);
64
+ }
65
+
66
+ const diagnostics = [...parseDiagnostics];
67
+ const session = createItemSession(parsed.document);
68
+ if (input.status) session.setStatus(input.status);
69
+
70
+ const responseIdentifiers = new Set(
71
+ parsed.document.item.responseDeclarations.map((declaration) => declaration.identifier),
72
+ );
73
+
74
+ for (const response of normalizeResponseInputs(input.trustedResponses)) {
75
+ const identifier = response.identifier.trim();
76
+ if (!identifier) {
77
+ diagnostics.push({
78
+ code: "serverScoring.response.identifier",
79
+ severity: "error",
80
+ message: "Trusted response identifiers must be non-empty strings.",
81
+ });
82
+ continue;
83
+ }
84
+
85
+ if (
86
+ !responseIdentifiers.has(identifier) &&
87
+ !allowedUndeclaredResponseIdentifiers.has(identifier)
88
+ ) {
89
+ diagnostics.push({
90
+ code: "serverScoring.response.ignored",
91
+ severity: "warning",
92
+ message: `Trusted response ${identifier} was ignored because it is not declared by the item.`,
93
+ });
94
+ continue;
95
+ }
96
+
97
+ const value = readQtiJsonValue(response.value);
98
+ if (value === undefined) {
99
+ diagnostics.push({
100
+ code: "serverScoring.response.value",
101
+ severity: "error",
102
+ message: `Trusted response ${identifier} is not a supported QTI value.`,
103
+ });
104
+ continue;
105
+ }
106
+ session.respond(identifier, value);
107
+ }
108
+
109
+ for (const [identifier, state] of Object.entries(input.trustedInteractionStates ?? {})) {
110
+ if (!isQtiPortableCustomStateValue(state)) {
111
+ diagnostics.push({
112
+ code: "serverScoring.interactionState.value",
113
+ severity: "error",
114
+ message: `Trusted interaction state ${identifier} is not a supported portable custom state value.`,
115
+ });
116
+ continue;
117
+ }
118
+
119
+ try {
120
+ session.setInteractionState(identifier, state);
121
+ } catch (error) {
122
+ diagnostics.push({
123
+ code: "serverScoring.interactionState.identifier",
124
+ severity: "error",
125
+ message: error instanceof Error ? error.message : String(error),
126
+ });
127
+ }
128
+ }
129
+
130
+ if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
131
+ return failed(diagnostics);
132
+ }
133
+
134
+ let scored: QtiScoreResult;
135
+ try {
136
+ scored = session.score();
137
+ } catch (error) {
138
+ return failed([
139
+ ...diagnostics,
140
+ {
141
+ code: "serverScoring.score.exception",
142
+ severity: "error",
143
+ message: error instanceof Error ? error.message : String(error),
144
+ },
145
+ ]);
146
+ }
147
+
148
+ const scoredDiagnostics = [...diagnostics, ...scored.diagnostics];
149
+ const score = readNumericScore(scored.outcomes.SCORE);
150
+ const scoredState = stripUndeclaredResponses(scored.state, responseIdentifiers);
151
+ if (scored.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
152
+ return failed(scoredDiagnostics, scoredState, scored.outcomes, score);
153
+ }
154
+
155
+ if (shouldRequireNumericScore(parsed.document.item) && score === null) {
156
+ return failed(
157
+ [
158
+ ...scoredDiagnostics,
159
+ {
160
+ code: "serverScoring.score.missing",
161
+ severity: "error",
162
+ message: "Server-side scoring did not produce a numeric SCORE outcome.",
163
+ },
164
+ ],
165
+ scoredState,
166
+ scored.outcomes,
167
+ score,
168
+ );
169
+ }
170
+
171
+ return {
172
+ ok: true,
173
+ diagnostics: scoredDiagnostics,
174
+ state: scoredState,
175
+ responses: scoredState.responses,
176
+ outcomes: scored.outcomes,
177
+ score,
178
+ };
179
+ }
180
+
181
+ function normalizeResponseInputs(
182
+ responses: QtiServerScoringInput["trustedResponses"],
183
+ ): QtiServerScoringResponseInput[] {
184
+ if (!responses) return [];
185
+ if (Array.isArray(responses)) return [...responses];
186
+ return Object.entries(responses).map(([identifier, value]) => ({ identifier, value }));
187
+ }
188
+
189
+ function shouldRequireNumericScore(item: QtiAssessmentItem): boolean {
190
+ return (
191
+ Boolean(item.responseProcessing) ||
192
+ item.outcomeDeclarations.some((declaration) => declaration.identifier === "SCORE")
193
+ );
194
+ }
195
+
196
+ function readNumericScore(value: QtiValue | undefined): number | null {
197
+ if (typeof value === "number" && Number.isFinite(value)) return value;
198
+ if (typeof value === "string") {
199
+ const score = Number(value.trim());
200
+ return Number.isFinite(score) ? score : null;
201
+ }
202
+ return null;
203
+ }
204
+
205
+ function isAllowedUndeclaredVariableReference(
206
+ diagnostic: QtiDiagnostic,
207
+ allowedUndeclaredResponseIdentifiers: Set<string>,
208
+ ): boolean {
209
+ if (diagnostic.code !== "processing.variable.reference") return false;
210
+ const identifier = diagnostic.message.match(
211
+ /^Processing expression references missing variable (.+)\.$/,
212
+ )?.[1];
213
+ return Boolean(identifier && allowedUndeclaredResponseIdentifiers.has(identifier));
214
+ }
215
+
216
+ function failed(
217
+ diagnostics: QtiDiagnostic[],
218
+ state: QtiAttemptStateV1 | null = null,
219
+ outcomes: Record<string, QtiValue> = {},
220
+ score: number | null = null,
221
+ ): QtiServerScoringResult {
222
+ return {
223
+ ok: false,
224
+ diagnostics,
225
+ state,
226
+ responses: state?.responses ?? {},
227
+ outcomes,
228
+ score,
229
+ };
230
+ }
231
+
232
+ function stripUndeclaredResponses(
233
+ state: QtiAttemptStateV1,
234
+ declaredResponseIdentifiers: Set<string>,
235
+ ): QtiAttemptStateV1 {
236
+ return {
237
+ ...state,
238
+ responses: Object.fromEntries(
239
+ Object.entries(state.responses).filter(([identifier]) =>
240
+ declaredResponseIdentifiers.has(identifier),
241
+ ),
242
+ ),
243
+ };
244
+ }