Skip to content

Commit 3ce5e6b

Browse files
rtibblesbotclaude
andcommitted
Embed raw Perseus questions as QTI custom interactions
Teach QTIExerciseGenerator to intercept PERSEUS_QUESTION items in process_assessment_item and emit each as a qti-custom-interaction: write the Perseus JSON (content-storage refs rewritten to the packaged asset paths) and its image/graphie assets into the package, and declare them as dependencies of the wrapper item's manifest resource. Removes the old ValueError rejection of Perseus questions in the QTI format. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ea97494 commit 3ce5e6b

2 files changed

Lines changed: 161 additions & 27 deletions

File tree

contentcuration/contentcuration/tests/utils/test_exercise_creation.py

Lines changed: 106 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import re
88
import zipfile
99
from io import BytesIO
10+
from tempfile import TemporaryDirectory
1011
from uuid import uuid4
1112

1213
from django.core.files.storage import default_storage as storage
@@ -27,6 +28,7 @@
2728
from contentcuration.utils.assessment.qti.archive import hex_to_qti_id
2829
from contentcuration.utils.assessment.qti.archive import QTIExerciseGenerator
2930
from contentcuration.utils.assessment.qti.validation import parse_qti_xml
31+
from contentcuration.utils.assessment.qti.validation import validate_qti_item
3032

3133

3234
class TestPerseusExerciseCreation(StudioTestCase):
@@ -480,6 +482,45 @@ def _create_perseus_item(self):
480482

481483
return item, graphie_files
482484

485+
def test_write_raw_perseus_assets_returns_paths_and_writes_files(self):
486+
"""`_write_raw_perseus_assets` writes an item's images/graphie assets into
487+
the given directory and returns their package-relative paths."""
488+
image_file = fileobj_exercise_image()
489+
graphie_file = fileobj_exercise_graphie(original_filename="mygraphie")
490+
491+
item = AssessmentItem.objects.create(
492+
contentnode=self.exercise_node,
493+
assessment_id="fedcba0987654321fedcba0987654321",
494+
type=exercises.PERSEUS_QUESTION,
495+
raw_data="{}",
496+
order=1,
497+
randomize=True,
498+
)
499+
image_file.assessment_item = item
500+
image_file.save()
501+
graphie_file.assessment_item = item
502+
graphie_file.save()
503+
504+
generator = PerseusExerciseGenerator(
505+
self.exercise_node, {}, self.channel.id, "en-US", user_id=self.user.id
506+
)
507+
with TemporaryDirectory() as tempdir:
508+
generator.tempdir = tempdir
509+
written = generator._write_raw_perseus_assets(item, "perseus/images")
510+
511+
image_path = (
512+
f"perseus/images/{image_file.checksum}.{image_file.file_format_id}"
513+
)
514+
svg_path = f"perseus/images/{graphie_file.original_filename}.svg"
515+
json_path = f"perseus/images/{graphie_file.original_filename}-data.json"
516+
517+
self.assertIn(image_path, written)
518+
self.assertIn(svg_path, written)
519+
self.assertIn(json_path, written)
520+
521+
for path in (image_path, svg_path, json_path):
522+
self.assertTrue(os.path.exists(os.path.join(tempdir, path)))
523+
483524
def test_exercise_with_graphie(self):
484525
"""Test creating an exercise with graphie files (SVG+JSON pairs)"""
485526

@@ -1454,32 +1495,83 @@ def test_qti_exercise_without_hints_produces_no_catalog_info(self):
14541495
item_xml = self._render_single_item_xml("abcdef1234567890abcdef1234567890", [])
14551496
self.assertNotIn("<qti-catalog-info", item_xml)
14561497

1457-
def test_perseus_question_rejection(self):
1458-
"""Test that Perseus questions are properly rejected"""
1459-
assessment_id = "aaaa1111bbbb2222cccc3333dddd4444"
1460-
# Create a mock Perseus question
1461-
item = AssessmentItem.objects.create(
1498+
def test_perseus_custom_interaction_embedded_with_native_qti(self):
1499+
"""A node mixing a native QTI item and a raw Perseus question yields one
1500+
QTI package: the native item, plus the Perseus question wrapped as a
1501+
``qti-custom-interaction`` with its JSON and image packaged and declared."""
1502+
native_id = "1234567890abcdef1234567890abcdef"
1503+
native_item = self._create_native_qti_item(
1504+
VALID_CHOICE_ITEM, assessment_id=native_id
1505+
)
1506+
1507+
image_file = fileobj_exercise_image()
1508+
image_url = exercises.CONTENT_STORAGE_FORMAT.format(image_file.filename())
1509+
perseus_id = "aaaa1111bbbb2222cccc3333dddd4444"
1510+
perseus_item = AssessmentItem.objects.create(
14621511
contentnode=self.exercise_node,
1463-
assessment_id=assessment_id,
1512+
assessment_id=perseus_id,
14641513
type=exercises.PERSEUS_QUESTION,
1465-
raw_data='{"question": {"content": "Perseus content"}}',
1466-
order=1,
1514+
raw_data=json.dumps(
1515+
{"question": {"content": f"See ![shape]({image_url})", "images": {}}},
1516+
ensure_ascii=False,
1517+
),
1518+
order=2,
1519+
randomize=False,
14671520
)
1521+
image_file.assessment_item = perseus_item
1522+
image_file.save()
14681523

14691524
exercise_data = {
14701525
"mastery_model": exercises.M_OF_N,
14711526
"randomize": True,
14721527
"n": 1,
14731528
"m": 1,
1474-
"all_assessment_items": [item.assessment_id],
1475-
"assessment_mapping": {item.assessment_id: exercises.PERSEUS_QUESTION},
1529+
"all_assessment_items": [
1530+
native_item.assessment_id,
1531+
perseus_item.assessment_id,
1532+
],
1533+
"assessment_mapping": {
1534+
native_item.assessment_id: exercises.QTI,
1535+
perseus_item.assessment_id: exercises.PERSEUS_QUESTION,
1536+
},
14761537
}
14771538

1478-
# Should raise ValueError for Perseus questions
1479-
with self.assertRaises(ValueError) as context:
1480-
self._create_qti_zip(exercise_data)
1539+
self._create_qti_zip(exercise_data)
1540+
exercise_file = self.exercise_node.files.get(preset_id=format_presets.QTI_ZIP)
1541+
zip_file = self._validate_qti_zip_structure(exercise_file)
1542+
1543+
namelist = zip_file.namelist()
1544+
perseus_item_path = f"items/{hex_to_qti_id(perseus_id)}.xml"
1545+
perseus_json_path = f"perseus/{perseus_id}.json"
1546+
image_path = f"perseus/images/{image_file.filename()}"
1547+
1548+
# Native QTI item and the Perseus wrapper item both present.
1549+
self.assertIn("items/item_1.xml", namelist)
1550+
self.assertIn(perseus_item_path, namelist)
1551+
# Perseus JSON and its image packaged.
1552+
self.assertIn(perseus_json_path, namelist)
1553+
self.assertIn(image_path, namelist)
1554+
1555+
# The wrapper item validates and references the Perseus JSON.
1556+
wrapper_xml = zip_file.read(perseus_item_path).decode("utf-8")
1557+
self.assertTrue(validate_qti_item(wrapper_xml.encode("utf-8")).is_valid)
1558+
parsed = parse_qti_xml(wrapper_xml.encode("utf-8"))
1559+
custom = parsed.getroot().iter("{*}qti-custom-interaction")
1560+
custom_el = next(custom)
1561+
self.assertEqual(custom_el.get("data-type"), "perseus")
1562+
self.assertEqual(custom_el.get("data-perseus-path"), perseus_json_path)
1563+
1564+
# The Perseus JSON's image reference was rewritten to the packaged path.
1565+
packaged_json = zip_file.read(perseus_json_path).decode("utf-8")
1566+
self.assertIn(
1567+
f"${exercises.IMG_PLACEHOLDER}/perseus/images/{image_file.filename()}",
1568+
packaged_json,
1569+
)
14811570

1482-
self.assertIn("Perseus questions are not supported", str(context.exception))
1571+
# The manifest lists the JSON and image as files of the perseus resource.
1572+
manifest_xml = zip_file.read("imsmanifest.xml").decode("utf-8")
1573+
self.assertIn(f'<file href="{perseus_json_path}" />', manifest_xml)
1574+
self.assertIn(f'<file href="{image_path}" />', manifest_xml)
14831575

14841576
def test_exercise_with_image(self):
14851577
"""Test QTI exercise generation with images"""

contentcuration/contentcuration/utils/assessment/qti/archive.py

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111

1212
from contentcuration.utils.assessment.base import ExerciseArchiveGenerator
1313
from contentcuration.utils.assessment.qti.constants import ResourceType
14+
from contentcuration.utils.assessment.qti.convert import (
15+
build_perseus_custom_interaction_item,
16+
)
1417
from contentcuration.utils.assessment.qti.convert import (
1518
convert_legacy_assessment_item_to_qti,
1619
)
@@ -43,6 +46,8 @@ class QTIExerciseGenerator(ExerciseArchiveGenerator):
4346
file_format = "zip"
4447
preset = format_presets.QTI_ZIP
4548

49+
PERSEUS_IMAGE_DIR = "perseus/images"
50+
4651
def __init__(self, *args, **kwargs):
4752
super().__init__(*args, **kwargs)
4853
self.qti_resources: List[QTIResource] = []
@@ -61,6 +66,16 @@ def get_image_ref_prefix(self):
6166
def _qti_item_filepath(self, assessment_id):
6267
return f"items/{assessment_id}.xml"
6368

69+
def _node_language(self):
70+
return (
71+
self.ccnode.language.lang_code
72+
if self.ccnode.language
73+
else self.default_language
74+
)
75+
76+
def _next_item_title(self):
77+
return f"{self.ccnode.title} {len(self.qti_resources) + 1}"
78+
6479
def _add_resource(self, resource: QTIResource) -> None:
6580
if any(r.identifier == resource.identifier for r in self.qti_resources):
6681
raise ValueError(
@@ -136,6 +151,44 @@ def _write_qti_media_files(self, assessment_item) -> Tuple[str, List[str]]:
136151
item_xml = rewrite_qti_media_paths(raw_data, path_by_filename)
137152
return item_xml, sorted(path_by_filename.values())
138153

154+
def process_assessment_item(self, assessment_item):
155+
if assessment_item.type == exercises.PERSEUS_QUESTION:
156+
return self._create_perseus_custom_interaction(assessment_item)
157+
return super().process_assessment_item(assessment_item)
158+
159+
def _create_perseus_custom_interaction(self, assessment_item) -> None:
160+
"""Embed a raw Perseus question as a ``qti-custom-interaction``.
161+
162+
Writes the Perseus JSON (with its content-storage references rewritten to
163+
the packaged asset paths) and its image/graphie assets into the package,
164+
and declares them as dependencies of the wrapper item's manifest resource.
165+
"""
166+
asset_paths = self._write_raw_perseus_assets(
167+
assessment_item, self.PERSEUS_IMAGE_DIR
168+
)
169+
perseus_json = self._rewrite_content_storage_refs(
170+
assessment_item.raw_data, self.PERSEUS_IMAGE_DIR
171+
)
172+
perseus_path = f"perseus/{assessment_item.assessment_id}.json"
173+
self.add_file_to_write(perseus_path, perseus_json.encode("utf-8"))
174+
175+
result = build_perseus_custom_interaction_item(
176+
assessment_item.assessment_id,
177+
perseus_path,
178+
self._next_item_title(),
179+
self._node_language(),
180+
)
181+
182+
item_path = self._qti_item_filepath(result.identifier)
183+
self.add_file_to_write(item_path, result.xml.encode("utf-8"))
184+
self._add_resource(
185+
QTIResource(
186+
identifier=result.identifier,
187+
filepath=item_path,
188+
file_dependencies=[perseus_path, *asset_paths],
189+
)
190+
)
191+
139192
def create_assessment_item(
140193
self, assessment_item, processed_data: Dict[str, Any]
141194
) -> Optional[Tuple[str, bytes]]:
@@ -144,26 +197,15 @@ def create_assessment_item(
144197
if assessment_item.type == exercises.QTI:
145198
return self._create_native_qti_item(assessment_item)
146199

147-
# Skip Perseus questions as they can't be easily converted
148-
if assessment_item.type == exercises.PERSEUS_QUESTION:
149-
raise ValueError(
150-
f"Perseus questions are not supported in QTI format: {assessment_item.assessment_id}"
151-
)
152-
153-
language = (
154-
self.ccnode.language.lang_code
155-
if self.ccnode.language
156-
else self.default_language
157-
)
158200
legacy_item = LegacyAssessmentItem(
159201
type=assessment_item.type,
160202
question=processed_data["question"],
161203
answers=processed_data.get("answers", []),
162204
hints=processed_data.get("hints", []),
163205
randomize=processed_data.get("randomize", False),
164206
assessment_id=assessment_item.assessment_id,
165-
title=f"{self.ccnode.title} {len(self.qti_resources) + 1}",
166-
language=language,
207+
title=self._next_item_title(),
208+
language=self._node_language(),
167209
)
168210
result = convert_legacy_assessment_item_to_qti(legacy_item)
169211

0 commit comments

Comments
 (0)