@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/README.md +85 -8
- package/dist/delivery-security.d.ts +26 -0
- package/dist/delivery-security.d.ts.map +1 -0
- package/dist/delivery-security.js +213 -0
- package/dist/delivery-security.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +21 -3
- package/dist/parser.js.map +1 -1
- package/dist/server-scoring.d.ts +27 -0
- package/dist/server-scoring.d.ts.map +1 -0
- package/dist/server-scoring.js +162 -0
- package/dist/server-scoring.js.map +1 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +192 -105
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +136 -3
- package/dist/validation.js.map +1 -1
- package/dist/value-format.d.ts +11 -0
- package/dist/value-format.d.ts.map +1 -0
- package/dist/value-format.js +107 -0
- package/dist/value-format.js.map +1 -0
- package/dist/xml.d.ts +12 -0
- package/dist/xml.d.ts.map +1 -1
- package/dist/xml.js +200 -50
- package/dist/xml.js.map +1 -1
- package/package.json +2 -2
- package/src/delivery-security.ts +283 -0
- package/src/index.ts +25 -0
- package/src/parser.ts +23 -3
- package/src/server-scoring.ts +244 -0
- package/src/session.ts +336 -291
- package/src/types.ts +3 -0
- package/src/validation.ts +151 -2
- package/src/value-format.ts +103 -0
- package/src/xml.ts +224 -52
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 =
|
|
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:
|
|
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 ===
|
|
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
|
+
}
|