QuestionBankToolkit 0.1.0__py3-none-any.whl
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.
- coursebanks/__init__.py +15 -0
- coursebanks/__main__.py +17 -0
- coursebanks/archive_io.py +158 -0
- coursebanks/assessments.py +114 -0
- coursebanks/cli.py +678 -0
- coursebanks/core.py +1392 -0
- coursebanks/migrate.py +645 -0
- coursebanks/models.py +212 -0
- coursebanks/py.typed +0 -0
- coursebanks/rotation.py +399 -0
- coursebanks/xml_io.py +50 -0
- coursebanks/yaml_data.py +147 -0
- questionbanktoolkit-0.1.0.dist-info/METADATA +125 -0
- questionbanktoolkit-0.1.0.dist-info/RECORD +18 -0
- questionbanktoolkit-0.1.0.dist-info/WHEEL +5 -0
- questionbanktoolkit-0.1.0.dist-info/entry_points.txt +2 -0
- questionbanktoolkit-0.1.0.dist-info/licenses/LICENSE +232 -0
- questionbanktoolkit-0.1.0.dist-info/top_level.txt +1 -0
coursebanks/migrate.py
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"""Strict migration of simple QTI 1.2 single-answer MC questions to YAML.
|
|
2
|
+
|
|
3
|
+
Unsupported QTI item structures are rejected rather than silently discarded.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import csv
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
from zipfile import ZipFile
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from .archive_io import read_member, validate_archive
|
|
18
|
+
from .core import MIN_CHOICES, QTI, parse_bank, verify_qti
|
|
19
|
+
from .models import Bank
|
|
20
|
+
from .xml_io import parse_xml
|
|
21
|
+
from .yaml_data import YamlMapping, YamlValue, yaml_value
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from xml.etree.ElementTree import Element
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class _Presentation:
|
|
29
|
+
"""Hold extracted question text before bank schema validation.
|
|
30
|
+
|
|
31
|
+
Attributes
|
|
32
|
+
----------
|
|
33
|
+
stem
|
|
34
|
+
Text from the stem's mattext element, or an empty string when the
|
|
35
|
+
element has no text. Nonempty-content validation occurs later.
|
|
36
|
+
text_format
|
|
37
|
+
QTI MIME type: ``text/plain`` or ``text/html`` when constructed
|
|
38
|
+
by the presentation parser.
|
|
39
|
+
|
|
40
|
+
Notes
|
|
41
|
+
-----
|
|
42
|
+
Instances are frozen. Construction alone does not validate field values.
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
stem: str
|
|
47
|
+
text_format: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class _Choices:
|
|
52
|
+
"""Hold ordered choices and translations of their source identifiers.
|
|
53
|
+
|
|
54
|
+
Attributes
|
|
55
|
+
----------
|
|
56
|
+
values
|
|
57
|
+
Mapping from generated labels A through Z to extracted choice text,
|
|
58
|
+
in source order. Values still require bank schema validation.
|
|
59
|
+
labels
|
|
60
|
+
Mapping from original QTI choice identifiers to generated labels,
|
|
61
|
+
used to translate the correct answer.
|
|
62
|
+
shuffle_choices
|
|
63
|
+
Whether the QTI render_choice element requests choice shuffling.
|
|
64
|
+
|
|
65
|
+
Notes
|
|
66
|
+
-----
|
|
67
|
+
Attribute assignment is frozen, but the contained dictionaries remain
|
|
68
|
+
mutable. Construction alone does not validate their contents.
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
values: YamlMapping
|
|
73
|
+
labels: dict[str, str]
|
|
74
|
+
shuffle_choices: bool
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def import_qti(
|
|
78
|
+
path: str | Path,
|
|
79
|
+
course: str,
|
|
80
|
+
bank_id: str,
|
|
81
|
+
title: str,
|
|
82
|
+
metadata_csv: str | Path | None = None,
|
|
83
|
+
) -> Bank:
|
|
84
|
+
"""Import supported single-answer QTI 1.2 items as a validated bank.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
path
|
|
89
|
+
Input ZIP containing imsmanifest.xml and its referenced QTI document.
|
|
90
|
+
course
|
|
91
|
+
Course identifier for the resulting bank.
|
|
92
|
+
bank_id
|
|
93
|
+
Bank identifier for the resulting bank; must satisfy the YAML schema.
|
|
94
|
+
title
|
|
95
|
+
Display title for the resulting bank.
|
|
96
|
+
metadata_csv
|
|
97
|
+
Optional UTF-8 CSV keyed by the case-sensitive ID column. Matching
|
|
98
|
+
rows provide authoring overrides. None means no CSV overrides.
|
|
99
|
+
|
|
100
|
+
Returns
|
|
101
|
+
-------
|
|
102
|
+
bank
|
|
103
|
+
Validated schema-version-1 bank with questions in document order.
|
|
104
|
+
Choices are relabeled A through Z in source order, the QTI shuffle
|
|
105
|
+
setting is retained per question, and legacy exam eligibility is
|
|
106
|
+
normalized to assessments.
|
|
107
|
+
|
|
108
|
+
Raises
|
|
109
|
+
------
|
|
110
|
+
ValueError
|
|
111
|
+
If archive resource limits or member-name uniqueness checks fail,
|
|
112
|
+
XML is unsafe or malformed, supported QTI structural checks fail,
|
|
113
|
+
metadata cannot be interpreted, or the resulting bank is invalid.
|
|
114
|
+
Final schema errors are prefixed with "Imported data needs attention".
|
|
115
|
+
OSError
|
|
116
|
+
If the archive or metadata CSV cannot be read.
|
|
117
|
+
UnicodeError
|
|
118
|
+
If the metadata CSV cannot be decoded as UTF-8.
|
|
119
|
+
csv.Error
|
|
120
|
+
If metadata CSV parsing fails.
|
|
121
|
+
zipfile.BadZipFile
|
|
122
|
+
If the input is not a readable ZIP archive.
|
|
123
|
+
|
|
124
|
+
Notes
|
|
125
|
+
-----
|
|
126
|
+
No files are written. Missing metadata uses field-specific defaults:
|
|
127
|
+
Unclassified for area/topic, Moderate for difficulty, unrestricted
|
|
128
|
+
eligibility, 2.5 minutes, 2 points, and unshuffled choices when QTI omits
|
|
129
|
+
the shuffle attribute. CSV overrides and QTI metadata are resolved before
|
|
130
|
+
schema validation; missing explanations are rejected.
|
|
131
|
+
|
|
132
|
+
""" # noqa: DOC503
|
|
133
|
+
report = verify_qti(path)
|
|
134
|
+
extras = _read_metadata_csv(metadata_csv)
|
|
135
|
+
with ZipFile(path) as archive:
|
|
136
|
+
members = validate_archive(archive)
|
|
137
|
+
assessment = members.get(report["assessment_file"])
|
|
138
|
+
if assessment is None:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
"Verified QTI assessment member is no longer present"
|
|
141
|
+
)
|
|
142
|
+
root = parse_xml(read_member(archive, assessment))
|
|
143
|
+
questions: list[YamlValue] = [
|
|
144
|
+
_import_item(item, extras) for item in root.findall(f".//{{{QTI}}}item")
|
|
145
|
+
]
|
|
146
|
+
bank: YamlMapping = {
|
|
147
|
+
"schema_version": 1,
|
|
148
|
+
"course": course,
|
|
149
|
+
"bank_id": bank_id,
|
|
150
|
+
"title": title,
|
|
151
|
+
"questions": questions,
|
|
152
|
+
}
|
|
153
|
+
try:
|
|
154
|
+
return parse_bank(bank)
|
|
155
|
+
except ValueError as exc:
|
|
156
|
+
raise ValueError(f"Imported data needs attention:\n{exc}") from exc
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _read_metadata_csv(
|
|
160
|
+
metadata_csv: str | Path | None,
|
|
161
|
+
) -> dict[str, dict[str, str]]:
|
|
162
|
+
"""Read authoring overrides keyed by the CSV ID column.
|
|
163
|
+
|
|
164
|
+
Parameters
|
|
165
|
+
----------
|
|
166
|
+
metadata_csv
|
|
167
|
+
UTF-8 CSV path, optionally with a byte-order mark. None or an empty
|
|
168
|
+
path string returns an empty mapping without accessing a file.
|
|
169
|
+
|
|
170
|
+
Returns
|
|
171
|
+
-------
|
|
172
|
+
extras
|
|
173
|
+
Mapping from question ID to string-valued cells, retaining the
|
|
174
|
+
original column names and text. Rows without a nonempty ID are
|
|
175
|
+
skipped; later rows replace earlier rows with the same ID.
|
|
176
|
+
|
|
177
|
+
Raises
|
|
178
|
+
------
|
|
179
|
+
OSError
|
|
180
|
+
If the CSV cannot be opened or read.
|
|
181
|
+
UnicodeError
|
|
182
|
+
If its content cannot be decoded as UTF-8.
|
|
183
|
+
csv.Error
|
|
184
|
+
If CSV parsing fails.
|
|
185
|
+
ValueError
|
|
186
|
+
If the CSV lacks the required case-sensitive ID column or a row has
|
|
187
|
+
more cells than the header.
|
|
188
|
+
|
|
189
|
+
Notes
|
|
190
|
+
-----
|
|
191
|
+
Column names and IDs are case-sensitive and are not stripped.
|
|
192
|
+
Schema validation and metadata interpretation occur later.
|
|
193
|
+
|
|
194
|
+
""" # noqa: DOC503
|
|
195
|
+
extras: dict[str, dict[str, str]] = {}
|
|
196
|
+
if metadata_csv:
|
|
197
|
+
with open(metadata_csv, newline="", encoding="utf-8-sig") as inp:
|
|
198
|
+
reader = csv.DictReader(inp, strict=True)
|
|
199
|
+
if reader.fieldnames is None or "ID" not in reader.fieldnames:
|
|
200
|
+
raise ValueError(
|
|
201
|
+
"Metadata CSV must contain a case-sensitive ID column"
|
|
202
|
+
)
|
|
203
|
+
for row in reader:
|
|
204
|
+
if None in row:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f"Metadata CSV row {reader.line_num} has more cells "
|
|
207
|
+
"than its header"
|
|
208
|
+
)
|
|
209
|
+
if row.get("ID"):
|
|
210
|
+
extras[row["ID"]] = {
|
|
211
|
+
key: value
|
|
212
|
+
for key, value in row.items()
|
|
213
|
+
if isinstance(key, str) and isinstance(value, str)
|
|
214
|
+
}
|
|
215
|
+
return extras
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _read_item_metadata(item: Element) -> dict[str, str]:
|
|
219
|
+
"""Read metadata fields beneath one QTI item.
|
|
220
|
+
|
|
221
|
+
Parameters
|
|
222
|
+
----------
|
|
223
|
+
item
|
|
224
|
+
Parsed QTI item element to inspect without modification.
|
|
225
|
+
|
|
226
|
+
Returns
|
|
227
|
+
-------
|
|
228
|
+
metadata
|
|
229
|
+
Field labels mapped to entry text. Later fields replace earlier
|
|
230
|
+
values for the same label. Fields lacking a label or entry element
|
|
231
|
+
are skipped; present but empty elements retain empty strings.
|
|
232
|
+
|
|
233
|
+
"""
|
|
234
|
+
metadata: dict[str, str] = {}
|
|
235
|
+
for f in item.findall(f".//{{{QTI}}}qtimetadatafield"):
|
|
236
|
+
key = f.findtext(f"{{{QTI}}}fieldlabel")
|
|
237
|
+
value = f.findtext(f"{{{QTI}}}fieldentry")
|
|
238
|
+
if key is not None and value is not None:
|
|
239
|
+
metadata[key] = value
|
|
240
|
+
return metadata
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _parse_presentation(item: Element, ident: str) -> _Presentation:
|
|
244
|
+
"""Extract a single-answer presentation with a plain-text or HTML stem.
|
|
245
|
+
|
|
246
|
+
Parameters
|
|
247
|
+
----------
|
|
248
|
+
item
|
|
249
|
+
Parsed QTI item containing the presentation to inspect.
|
|
250
|
+
ident
|
|
251
|
+
Question identifier used in error messages.
|
|
252
|
+
|
|
253
|
+
Returns
|
|
254
|
+
-------
|
|
255
|
+
presentation
|
|
256
|
+
Extracted stem text and its QTI MIME type. Missing text becomes an
|
|
257
|
+
empty string; a missing texttype attribute defaults to text/plain.
|
|
258
|
+
|
|
259
|
+
Raises
|
|
260
|
+
------
|
|
261
|
+
ValueError
|
|
262
|
+
If there is not exactly one presentation response_lid with Single
|
|
263
|
+
cardinality, the stem mattext element is absent, or its texttype
|
|
264
|
+
is neither text/plain nor text/html.
|
|
265
|
+
|
|
266
|
+
Notes
|
|
267
|
+
-----
|
|
268
|
+
This checks supported presentation structure, not nonempty content or
|
|
269
|
+
HTML rendering. The XML item is not modified.
|
|
270
|
+
|
|
271
|
+
"""
|
|
272
|
+
response_lids = item.findall(
|
|
273
|
+
f"./{{{QTI}}}presentation/{{{QTI}}}response_lid"
|
|
274
|
+
)
|
|
275
|
+
if (
|
|
276
|
+
len(response_lids) != 1
|
|
277
|
+
or response_lids[0].get("rcardinality") != "Single"
|
|
278
|
+
):
|
|
279
|
+
raise ValueError(f"Unsupported response type in {ident}")
|
|
280
|
+
stemnode = item.find(
|
|
281
|
+
f"./{{{QTI}}}presentation/{{{QTI}}}material/{{{QTI}}}mattext"
|
|
282
|
+
)
|
|
283
|
+
if stemnode is None:
|
|
284
|
+
raise ValueError(f"Missing question stem in {ident}")
|
|
285
|
+
fmt = stemnode.get("texttype", "text/plain")
|
|
286
|
+
if fmt not in {"text/plain", "text/html"}:
|
|
287
|
+
raise ValueError(f"Unsupported mattext format {fmt} in {ident}")
|
|
288
|
+
return _Presentation(stemnode.text or "", fmt)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _parse_choices(item: Element, ident: str, fmt: str) -> _Choices:
|
|
292
|
+
"""Relabel choices in source order and retain identifier translations.
|
|
293
|
+
|
|
294
|
+
Parameters
|
|
295
|
+
----------
|
|
296
|
+
item
|
|
297
|
+
Parsed QTI item containing presentation response labels.
|
|
298
|
+
ident
|
|
299
|
+
Question identifier used in error messages.
|
|
300
|
+
fmt
|
|
301
|
+
Expected QTI MIME type taken from the parsed stem. Each choice must
|
|
302
|
+
use the same type; omitted choice texttype defaults to text/plain.
|
|
303
|
+
|
|
304
|
+
Returns
|
|
305
|
+
-------
|
|
306
|
+
choices
|
|
307
|
+
Extracted choice text keyed by A through Z, a mapping from original
|
|
308
|
+
identifiers to those labels, and the effective QTI shuffle setting.
|
|
309
|
+
Missing text becomes an empty string, to be checked by subsequent
|
|
310
|
+
bank validation.
|
|
311
|
+
|
|
312
|
+
Raises
|
|
313
|
+
------
|
|
314
|
+
ValueError
|
|
315
|
+
If render_choice has an invalid shuffle value, there are fewer than
|
|
316
|
+
two or more than 26 choices, a choice lacks a mattext element or
|
|
317
|
+
identifier, or its text format differs from fmt.
|
|
318
|
+
|
|
319
|
+
Notes
|
|
320
|
+
-----
|
|
321
|
+
The source item is unchanged. Duplicate source identifiers are not
|
|
322
|
+
rejected here; later occurrences replace earlier translation entries.
|
|
323
|
+
Empty or duplicate choice text is checked by bank schema validation.
|
|
324
|
+
|
|
325
|
+
"""
|
|
326
|
+
render = item.find(
|
|
327
|
+
f"./{{{QTI}}}presentation/{{{QTI}}}response_lid/{{{QTI}}}render_choice"
|
|
328
|
+
)
|
|
329
|
+
if render is None:
|
|
330
|
+
raise ValueError(f"Unsupported number of choices in {ident}")
|
|
331
|
+
shuffle = render.get("shuffle", "No")
|
|
332
|
+
if shuffle not in {"Yes", "No"}:
|
|
333
|
+
raise ValueError(f"Unsupported shuffle value in {ident}: {shuffle}")
|
|
334
|
+
labels = render.findall(f"{{{QTI}}}response_label")
|
|
335
|
+
keys = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
336
|
+
if len(labels) < MIN_CHOICES or len(labels) > len(keys):
|
|
337
|
+
raise ValueError(f"Unsupported number of choices in {ident}")
|
|
338
|
+
choices: YamlMapping = {}
|
|
339
|
+
mapping: dict[str, str] = {}
|
|
340
|
+
for n, lab in enumerate(labels):
|
|
341
|
+
mat = lab.find(f"./{{{QTI}}}material/{{{QTI}}}mattext")
|
|
342
|
+
if mat is None:
|
|
343
|
+
raise ValueError(f"Missing answer text in {ident}")
|
|
344
|
+
if mat.get("texttype", "text/plain") != fmt:
|
|
345
|
+
raise ValueError(f"Inconsistent response text formats in {ident}")
|
|
346
|
+
choices[keys[n]] = mat.text or ""
|
|
347
|
+
label_id = lab.get("ident")
|
|
348
|
+
if not label_id:
|
|
349
|
+
raise ValueError(f"Missing choice identifier in {ident}")
|
|
350
|
+
mapping[label_id] = keys[n]
|
|
351
|
+
return _Choices(choices, mapping, shuffle == "Yes")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _parse_correct_answer(
|
|
355
|
+
item: Element, ident: str, mapping: dict[str, str]
|
|
356
|
+
) -> str:
|
|
357
|
+
"""Translate the choice ID from one supported full-credit condition.
|
|
358
|
+
|
|
359
|
+
Parameters
|
|
360
|
+
----------
|
|
361
|
+
item
|
|
362
|
+
Parsed QTI item containing response-processing conditions.
|
|
363
|
+
ident
|
|
364
|
+
Question identifier used in error messages.
|
|
365
|
+
mapping
|
|
366
|
+
Original choice identifiers mapped to generated YAML labels.
|
|
367
|
+
|
|
368
|
+
Returns
|
|
369
|
+
-------
|
|
370
|
+
answer
|
|
371
|
+
Generated label for the unique matching correct-answer condition.
|
|
372
|
+
|
|
373
|
+
Raises
|
|
374
|
+
------
|
|
375
|
+
ValueError
|
|
376
|
+
If there is not exactly one condition with a varequal text value and
|
|
377
|
+
a setvar text value of 100, or its choice ID is absent from mapping.
|
|
378
|
+
|
|
379
|
+
Notes
|
|
380
|
+
-----
|
|
381
|
+
Score text is stripped before comparison with the literal "100".
|
|
382
|
+
Other score representations, such as "100.0", do not match. This helper
|
|
383
|
+
extracts the supported scoring pattern; it is not a general QTI scorer.
|
|
384
|
+
|
|
385
|
+
"""
|
|
386
|
+
conditions = item.findall(
|
|
387
|
+
f"./{{{QTI}}}resprocessing/{{{QTI}}}respcondition"
|
|
388
|
+
)
|
|
389
|
+
matches: list[str] = []
|
|
390
|
+
for condition in conditions:
|
|
391
|
+
eq = condition.find(f"./{{{QTI}}}conditionvar/{{{QTI}}}varequal")
|
|
392
|
+
score = condition.find(f"./{{{QTI}}}setvar")
|
|
393
|
+
if (
|
|
394
|
+
eq is not None
|
|
395
|
+
and eq.text is not None
|
|
396
|
+
and score is not None
|
|
397
|
+
and (score.text or "").strip() == "100"
|
|
398
|
+
):
|
|
399
|
+
matches.append(eq.text)
|
|
400
|
+
if len(matches) != 1 or matches[0] not in mapping:
|
|
401
|
+
raise ValueError(f"Cannot determine unique answer for {ident}")
|
|
402
|
+
return mapping[matches[0]]
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _parse_explanation(item: Element, ident: str) -> str:
|
|
406
|
+
"""Extract required feedback without silently losing an explanation.
|
|
407
|
+
|
|
408
|
+
Parameters
|
|
409
|
+
----------
|
|
410
|
+
item
|
|
411
|
+
Parsed QTI item containing itemfeedback/flow_mat/material/mattext.
|
|
412
|
+
ident
|
|
413
|
+
Question identifier used in error messages.
|
|
414
|
+
|
|
415
|
+
Returns
|
|
416
|
+
-------
|
|
417
|
+
explanation
|
|
418
|
+
Text from the first matching feedback element, or an empty string
|
|
419
|
+
if the element has no text. Bank validation checks for nonempty text.
|
|
420
|
+
|
|
421
|
+
Raises
|
|
422
|
+
------
|
|
423
|
+
ValueError
|
|
424
|
+
If no matching feedback mattext element exists.
|
|
425
|
+
|
|
426
|
+
"""
|
|
427
|
+
feedback = item.find(
|
|
428
|
+
f"./{{{QTI}}}itemfeedback/{{{QTI}}}flow_mat/{{{QTI}}}"
|
|
429
|
+
f"material/{{{QTI}}}mattext"
|
|
430
|
+
)
|
|
431
|
+
if feedback is None:
|
|
432
|
+
raise ValueError(
|
|
433
|
+
f"Missing explanation in {ident}; refusing lossy import"
|
|
434
|
+
)
|
|
435
|
+
return feedback.text or ""
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _resolve_eligibility(
|
|
439
|
+
e: dict[str, str], metadata: dict[str, str]
|
|
440
|
+
) -> YamlMapping:
|
|
441
|
+
"""Resolve assessment eligibility using CSV overrides before QTI fields.
|
|
442
|
+
|
|
443
|
+
Parameters
|
|
444
|
+
----------
|
|
445
|
+
e
|
|
446
|
+
String-valued CSV cells for this question, or an empty mapping.
|
|
447
|
+
metadata
|
|
448
|
+
QTI field labels mapped to their entry text.
|
|
449
|
+
|
|
450
|
+
Returns
|
|
451
|
+
-------
|
|
452
|
+
eligibility
|
|
453
|
+
YAML mapping containing either assessments decoded from JSON or a
|
|
454
|
+
legacy exam string. Values remain unvalidated until bank parsing,
|
|
455
|
+
which also normalizes legacy eligibility.
|
|
456
|
+
|
|
457
|
+
Raises
|
|
458
|
+
------
|
|
459
|
+
ValueError
|
|
460
|
+
If selected JSON is invalid or cannot be represented as supported
|
|
461
|
+
YAML data. Malformed JSON raises the JSONDecodeError subclass.
|
|
462
|
+
|
|
463
|
+
Notes
|
|
464
|
+
-----
|
|
465
|
+
Precedence is nonempty CSV Assessments, nonempty CSV Suggested exam,
|
|
466
|
+
QTI assessments when present, then QTI suggested_exam or Either.
|
|
467
|
+
Empty CSV strings supply no override. A JSON empty list explicitly
|
|
468
|
+
requests unrestricted eligibility. Neither input mapping is modified.
|
|
469
|
+
|
|
470
|
+
""" # noqa: DOC502
|
|
471
|
+
if e.get("Assessments"):
|
|
472
|
+
return {"assessments": yaml_value(json.loads(e["Assessments"]))}
|
|
473
|
+
elif e.get("Suggested exam"):
|
|
474
|
+
return {"exam": e["Suggested exam"]}
|
|
475
|
+
elif "assessments" in metadata:
|
|
476
|
+
return {"assessments": yaml_value(json.loads(metadata["assessments"]))}
|
|
477
|
+
else:
|
|
478
|
+
return {"exam": metadata.get("suggested_exam") or "Either"}
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _resolve_authoring_metadata(
|
|
482
|
+
item: Element, ident: str, e: dict[str, str], metadata: dict[str, str]
|
|
483
|
+
) -> YamlMapping:
|
|
484
|
+
"""Resolve authoring values, eligibility, and field-specific defaults.
|
|
485
|
+
|
|
486
|
+
Parameters
|
|
487
|
+
----------
|
|
488
|
+
item
|
|
489
|
+
QTI item supplying its title attribute.
|
|
490
|
+
ident
|
|
491
|
+
Question identifier used as the fallback title and to remove a
|
|
492
|
+
leading identifier followed by a space from an existing title.
|
|
493
|
+
e
|
|
494
|
+
String-valued CSV cells for this question.
|
|
495
|
+
metadata
|
|
496
|
+
QTI metadata field labels and entry text.
|
|
497
|
+
|
|
498
|
+
Returns
|
|
499
|
+
-------
|
|
500
|
+
fields
|
|
501
|
+
Unvalidated YAML mapping with title, area, topic, difficulty,
|
|
502
|
+
eligibility, minutes, and points. Integral point values become
|
|
503
|
+
integers; other point values remain floating-point numbers.
|
|
504
|
+
|
|
505
|
+
Raises
|
|
506
|
+
------
|
|
507
|
+
ValueError
|
|
508
|
+
If numeric text cannot be converted or eligibility decoding fails.
|
|
509
|
+
|
|
510
|
+
Notes
|
|
511
|
+
-----
|
|
512
|
+
Area uses CSV Area, QTI area, QTI topic, then Unclassified. Topic uses
|
|
513
|
+
CSV Topic, QTI topic, then Unclassified. Difficulty uses CSV Difficulty,
|
|
514
|
+
QTI difficulty, then Moderate; Easy becomes Basic and High becomes
|
|
515
|
+
Challenging. Minutes use CSV Suggested minutes or 2.5; points use QTI
|
|
516
|
+
points_possible or 2. Empty strings trigger these fallbacks.
|
|
517
|
+
Eligibility follows _resolve_eligibility. Neither input mapping nor the
|
|
518
|
+
item is modified. Schema constraints, including positivity and finiteness,
|
|
519
|
+
are checked later.
|
|
520
|
+
|
|
521
|
+
""" # noqa: DOC502
|
|
522
|
+
difficulty = e.get("Difficulty") or metadata.get("difficulty") or "Moderate"
|
|
523
|
+
difficulty = {"Easy": "Basic", "High": "Challenging"}.get(
|
|
524
|
+
difficulty, difficulty
|
|
525
|
+
)
|
|
526
|
+
pts = float(metadata.get("points_possible") or 2)
|
|
527
|
+
minutes = float(e.get("Suggested minutes") or 2.5)
|
|
528
|
+
raw_title = item.get("title") or ident
|
|
529
|
+
if raw_title.startswith(ident + " "):
|
|
530
|
+
raw_title = raw_title[len(ident) + 1 :]
|
|
531
|
+
eligibility = _resolve_eligibility(e, metadata)
|
|
532
|
+
return {
|
|
533
|
+
"title": raw_title,
|
|
534
|
+
"area": e.get("Area")
|
|
535
|
+
or metadata.get("area")
|
|
536
|
+
or metadata.get("topic")
|
|
537
|
+
or "Unclassified",
|
|
538
|
+
"topic": e.get("Topic") or metadata.get("topic") or "Unclassified",
|
|
539
|
+
"difficulty": difficulty,
|
|
540
|
+
**eligibility,
|
|
541
|
+
"minutes": minutes,
|
|
542
|
+
"points": int(pts) if pts.is_integer() else pts,
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _import_item(
|
|
547
|
+
item: Element, extras: dict[str, dict[str, str]]
|
|
548
|
+
) -> YamlMapping:
|
|
549
|
+
"""Extract one question before final bank schema validation.
|
|
550
|
+
|
|
551
|
+
Parameters
|
|
552
|
+
----------
|
|
553
|
+
item
|
|
554
|
+
Parsed QTI item from an archive that has passed structural
|
|
555
|
+
verification.
|
|
556
|
+
extras
|
|
557
|
+
CSV overrides keyed by original question ID. A missing ID entry
|
|
558
|
+
supplies no overrides for that question.
|
|
559
|
+
|
|
560
|
+
Returns
|
|
561
|
+
-------
|
|
562
|
+
question
|
|
563
|
+
Unvalidated YAML mapping containing question content, relabeled
|
|
564
|
+
choices, effective shuffling, translated answer, feedback, and resolved
|
|
565
|
+
authoring fields. QTI text/plain and text/html become plain and html
|
|
566
|
+
respectively.
|
|
567
|
+
|
|
568
|
+
Raises
|
|
569
|
+
------
|
|
570
|
+
ValueError
|
|
571
|
+
If the item lacks an identifier, a supported content or scoring
|
|
572
|
+
check fails, or authoring metadata cannot be interpreted.
|
|
573
|
+
|
|
574
|
+
Notes
|
|
575
|
+
-----
|
|
576
|
+
Checks run in presentation, choices, scoring, then feedback order before
|
|
577
|
+
authoring metadata is resolved. XML and overrides are unchanged.
|
|
578
|
+
The result must pass parse_bank before it is treated as a valid Question.
|
|
579
|
+
|
|
580
|
+
"""
|
|
581
|
+
ident = item.get("ident")
|
|
582
|
+
if not ident:
|
|
583
|
+
raise ValueError("QTI item missing identifier")
|
|
584
|
+
metadata = _read_item_metadata(item)
|
|
585
|
+
presentation = _parse_presentation(item, ident)
|
|
586
|
+
choices = _parse_choices(item, ident, presentation.text_format)
|
|
587
|
+
answer = _parse_correct_answer(item, ident, choices.labels)
|
|
588
|
+
explanation = _parse_explanation(item, ident)
|
|
589
|
+
authoring = _resolve_authoring_metadata(
|
|
590
|
+
item, ident, extras.get(ident, {}), metadata
|
|
591
|
+
)
|
|
592
|
+
return {
|
|
593
|
+
"id": ident,
|
|
594
|
+
**authoring,
|
|
595
|
+
"format": "html"
|
|
596
|
+
if presentation.text_format == "text/html"
|
|
597
|
+
else "plain",
|
|
598
|
+
"stem": presentation.stem,
|
|
599
|
+
"choices": choices.values,
|
|
600
|
+
"shuffle_choices": choices.shuffle_choices,
|
|
601
|
+
"answer": answer,
|
|
602
|
+
"explanation": explanation,
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def save_bank(bank: object, out: str | Path) -> Path:
|
|
607
|
+
"""Serialize YAML without schema validation and return the output path.
|
|
608
|
+
|
|
609
|
+
Parameters
|
|
610
|
+
----------
|
|
611
|
+
bank
|
|
612
|
+
Bank or draft data representable by yaml.safe_dump. Schema validation
|
|
613
|
+
is not performed; call parse_bank first when compliance is required.
|
|
614
|
+
out
|
|
615
|
+
Destination YAML path. Parent directories are created as needed,
|
|
616
|
+
and any existing file is overwritten.
|
|
617
|
+
|
|
618
|
+
Returns
|
|
619
|
+
-------
|
|
620
|
+
path
|
|
621
|
+
Destination as a Path, without resolving it to an absolute path.
|
|
622
|
+
|
|
623
|
+
Raises
|
|
624
|
+
------
|
|
625
|
+
OSError
|
|
626
|
+
If directory creation or writing fails.
|
|
627
|
+
UnicodeError
|
|
628
|
+
If output cannot be encoded as UTF-8.
|
|
629
|
+
yaml.YAMLError
|
|
630
|
+
If data cannot be represented as safe YAML.
|
|
631
|
+
|
|
632
|
+
Notes
|
|
633
|
+
-----
|
|
634
|
+
Output preserves mapping insertion order, Unicode text, and supported
|
|
635
|
+
optional metadata. YAML comments and original source formatting are not
|
|
636
|
+
retained. Input data is not modified.
|
|
637
|
+
|
|
638
|
+
""" # noqa: DOC502
|
|
639
|
+
out = Path(out)
|
|
640
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
641
|
+
out.write_text(
|
|
642
|
+
yaml.safe_dump(bank, allow_unicode=True, sort_keys=False, width=110),
|
|
643
|
+
encoding="utf-8",
|
|
644
|
+
)
|
|
645
|
+
return out
|