Skip to content

Commit 5e9ac73

Browse files
rtibblesbotclaude
andcommitted
feat: emit legacy assessment hints as QTI catalog-info
Legacy assessment items carry progressively-revealed hints (AssessmentItem.hints) that the QTI generator dropped. Emit them into the generated QTI item as a dormant qti-catalog-info catalog of support="ext:kolibri-hint" cards - content the QTI 3.0 schema excludes from default delivery - so Kolibri can render them while default players ignore them. One qti-card per hint in display order, hint markdown converted to HTML via the existing render pipeline (so hint images are claimed by the existing file-dependency scan). Items with no usable hints emit no qti-catalog-info. Wired through both the webapp publish path (archive.py) and the ricecooker ingest path (ingest.py). Malformed hints are logged and skipped rather than aborting the channel publish. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5e86bbb commit 5e9ac73

10 files changed

Lines changed: 355 additions & 2 deletions

File tree

contentcuration/contentcuration/tests/utils/qti/test_assessment_items.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
from contentcuration.utils.assessment.qti.assessment_item import ResponseDeclaration
1111
from contentcuration.utils.assessment.qti.assessment_item import ResponseProcessing
1212
from contentcuration.utils.assessment.qti.assessment_item import Value
13+
from contentcuration.utils.assessment.qti.catalog import Card
14+
from contentcuration.utils.assessment.qti.catalog import Catalog
15+
from contentcuration.utils.assessment.qti.catalog import CatalogInfo
16+
from contentcuration.utils.assessment.qti.catalog import HtmlContent
1317
from contentcuration.utils.assessment.qti.constants import BaseType
1418
from contentcuration.utils.assessment.qti.constants import Cardinality
1519
from contentcuration.utils.assessment.qti.html import Blockquote
@@ -31,6 +35,39 @@
3135

3236

3337
class QTIAssessmentItemTests(unittest.TestCase):
38+
def test_assessment_item_with_catalog_info_orders_between_item_body_and_response_processing(
39+
self,
40+
):
41+
item_body = ItemBody(children=[P(children=["Question."])])
42+
response_processing = ResponseProcessing(
43+
template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct"
44+
)
45+
catalog_info = CatalogInfo(
46+
catalog=[
47+
Catalog(
48+
id_="kolibri-hints",
49+
card=[
50+
Card(html_content=HtmlContent(children=[P(children=["Hint."])]))
51+
],
52+
)
53+
]
54+
)
55+
56+
assessment_item = AssessmentItem(
57+
identifier="item1",
58+
title="Test",
59+
language="en-US",
60+
item_body=item_body,
61+
catalog_info=catalog_info,
62+
response_processing=response_processing,
63+
)
64+
65+
xml = assessment_item.to_xml_string()
66+
self.assertLess(xml.index("</qti-item-body>"), xml.index("<qti-catalog-info>"))
67+
self.assertLess(
68+
xml.index("</qti-catalog-info>"), xml.index("<qti-response-processing")
69+
)
70+
3471
def test_true_false_question(self):
3572
expected_xml = """<qti-assessment-item
3673
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import unittest
2+
3+
from contentcuration.utils.assessment.qti.catalog import Card
4+
from contentcuration.utils.assessment.qti.catalog import Catalog
5+
from contentcuration.utils.assessment.qti.catalog import CatalogInfo
6+
from contentcuration.utils.assessment.qti.catalog import HtmlContent
7+
from contentcuration.utils.assessment.qti.html import P
8+
9+
10+
class CatalogElementXMLOutputTests(unittest.TestCase):
11+
def test_html_content_to_xml_string(self):
12+
html_content = HtmlContent(children=[P(children=["First hint."])])
13+
self.assertEqual(
14+
html_content.to_xml_string(),
15+
"<qti-html-content><p>First hint.</p></qti-html-content>",
16+
)
17+
18+
def test_card_uses_kolibri_hint_support_by_default(self):
19+
card = Card(html_content=HtmlContent(children=[P(children=["Hint."])]))
20+
self.assertEqual(card.support, "ext:kolibri-hint")
21+
self.assertEqual(
22+
card.to_xml_string(),
23+
'<qti-card support="ext:kolibri-hint">'
24+
"<qti-html-content><p>Hint.</p></qti-html-content></qti-card>",
25+
)
26+
27+
def test_catalog_to_xml_string(self):
28+
card = Card(html_content=HtmlContent(children=[P(children=["Hint."])]))
29+
catalog = Catalog(id_="kolibri-hints", card=[card])
30+
self.assertEqual(
31+
catalog.to_xml_string(),
32+
'<qti-catalog id="kolibri-hints"><qti-card support="ext:kolibri-hint">'
33+
"<qti-html-content><p>Hint.</p></qti-html-content>"
34+
"</qti-card></qti-catalog>",
35+
)
36+
37+
def test_catalog_requires_at_least_one_card(self):
38+
with self.assertRaises(ValueError):
39+
Catalog(id_="kolibri-hints", card=[])
40+
41+
def test_catalog_info_to_xml_string(self):
42+
card = Card(html_content=HtmlContent(children=[P(children=["Hint."])]))
43+
catalog_info = CatalogInfo(catalog=[Catalog(id_="kolibri-hints", card=[card])])
44+
self.assertEqual(
45+
catalog_info.to_xml_string(),
46+
"<qti-catalog-info>"
47+
'<qti-catalog id="kolibri-hints"><qti-card support="ext:kolibri-hint">'
48+
"<qti-html-content><p>Hint.</p></qti-html-content>"
49+
"</qti-card></qti-catalog></qti-catalog-info>",
50+
)
51+
52+
def test_catalog_info_requires_at_least_one_catalog(self):
53+
with self.assertRaises(ValueError):
54+
CatalogInfo(catalog=[])

contentcuration/contentcuration/tests/utils/qti/test_convert.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def _make_item(
3030
randomize=False,
3131
title="Test Question 1",
3232
language="en-US",
33+
hints=None,
3334
):
3435
return LegacyAssessmentItem(
3536
type=type,
@@ -39,6 +40,7 @@ def _make_item(
3940
assessment_id=assessment_id,
4041
title=title,
4142
language=language,
43+
hints=hints or [],
4244
)
4345

4446

@@ -244,3 +246,89 @@ def test_unsupported_type_raises(self):
244246
convert_legacy_assessment_item_to_qti(item)
245247

246248
self.assertIn("Unsupported question type", str(ctx.exception))
249+
250+
251+
class CatalogInfoConversionTests(unittest.TestCase):
252+
def _item_with_hints(self, hints, assessment_id="1234567890abcdef1234567890abcdef"):
253+
return _make_item(
254+
type=exercises.SINGLE_SELECTION,
255+
question="What is 2+2?",
256+
answers=[
257+
{"answer": "4", "correct": True, "order": 1},
258+
{"answer": "3", "correct": False, "order": 2},
259+
],
260+
assessment_id=assessment_id,
261+
hints=hints,
262+
)
263+
264+
def test_no_hints_produces_no_catalog_info(self):
265+
item = self._item_with_hints([])
266+
result = convert_legacy_assessment_item_to_qti(item)
267+
self.assertNotIn("<qti-catalog-info", result.xml)
268+
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
269+
270+
def test_multi_hint_ordering_is_independent_of_input_list_order(self):
271+
item = self._item_with_hints(
272+
[
273+
{"hint": "Second hint", "order": 2},
274+
{"hint": "First hint", "order": 1},
275+
]
276+
)
277+
result = convert_legacy_assessment_item_to_qti(item)
278+
self.assertEqual(result.xml.count('support="ext:kolibri-hint"'), 2)
279+
self.assertLess(result.xml.index("First hint"), result.xml.index("Second hint"))
280+
self.assertLess(
281+
result.xml.index("</qti-item-body>"), result.xml.index("<qti-catalog-info>")
282+
)
283+
self.assertLess(
284+
result.xml.index("</qti-catalog-info>"),
285+
result.xml.index("<qti-response-processing"),
286+
)
287+
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
288+
289+
def test_hint_with_image_registers_file_dependency(self):
290+
item = self._item_with_hints(
291+
[{"hint": "See ![diagram](images/hint123.png)", "order": 1}]
292+
)
293+
result = convert_legacy_assessment_item_to_qti(item)
294+
self.assertIn('<img alt="diagram" src="images/hint123.png" />', result.xml)
295+
self.assertIn("images/hint123.png", result.file_dependencies)
296+
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
297+
298+
def test_hint_missing_order_key_does_not_raise(self):
299+
# A malformed hint must degrade gracefully rather than raise: an uncaught
300+
# exception here would abort the entire channel's publish, not just this item.
301+
item = self._item_with_hints([{"hint": "Undated hint"}])
302+
result = convert_legacy_assessment_item_to_qti(item)
303+
self.assertIn("Undated hint", result.xml)
304+
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
305+
306+
def test_hint_with_incomparable_order_values_does_not_raise(self):
307+
# A mixed-type "order" (e.g. a string alongside an int, or None) makes
308+
# sorted() raise TypeError - this must fall back to input order rather
309+
# than crash the channel publish.
310+
item = self._item_with_hints(
311+
[{"hint": "First hint", "order": 1}, {"hint": "Second hint", "order": "2"}]
312+
)
313+
result = convert_legacy_assessment_item_to_qti(item)
314+
self.assertEqual(result.xml.count('support="ext:kolibri-hint"'), 2)
315+
self.assertIn("First hint", result.xml)
316+
self.assertIn("Second hint", result.xml)
317+
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
318+
319+
def test_hint_missing_text_key_is_logged_and_skipped(self):
320+
# Graceful-degradation contract for a hint dict with no "hint" value:
321+
# log + skip it, never crash the channel publish. With this the only
322+
# hint, no card survives, so no qti-catalog-info is emitted at all.
323+
item = self._item_with_hints([{"order": 1}])
324+
result = convert_legacy_assessment_item_to_qti(item)
325+
self.assertNotIn("<qti-catalog-info", result.xml)
326+
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
327+
328+
def test_partial_malformed_hints_keep_valid_siblings(self):
329+
# A malformed hint is skipped while its valid siblings still render.
330+
item = self._item_with_hints([{"hint": "Real hint", "order": 1}, {"order": 2}])
331+
result = convert_legacy_assessment_item_to_qti(item)
332+
self.assertEqual(result.xml.count('support="ext:kolibri-hint"'), 1)
333+
self.assertIn("Real hint", result.xml)
334+
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)

contentcuration/contentcuration/tests/utils/qti/test_ingest.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,39 @@ def test_convert_legacy_question_to_qti_strips_placeholder_from_question_and_ans
4343
},
4444
)
4545

46+
def test_convert_legacy_question_to_qti_strips_placeholder_from_hints_and_registers_image(
47+
self,
48+
):
49+
question_data = {
50+
"type": "multiple_selection",
51+
"assessment_id": "abf45e8fd7f151adb1b3df2d751e945e",
52+
"question": "Which is red?",
53+
"answers": '[{"answer": "Apple", "correct": true, "order": 0}, {"answer": "Sky", "correct": false, "order": 1}]', # noqa
54+
"hints": '[{"hint": "Try this: ![](${☣ CONTENTSTORAGE}/cccccccccccccccccccccccccccccccc.png)", "order": 0}]', # noqa
55+
"randomize": False,
56+
}
57+
result = convert_legacy_question_to_qti(question_data)
58+
validation_result = validate_qti_item(result.xml)
59+
self.assertTrue(validation_result.is_valid, validation_result.errors)
60+
self.assertIn('support="ext:kolibri-hint"', result.xml)
61+
self.assertIn(
62+
"cccccccccccccccccccccccccccccccc.png",
63+
get_qti_media_references(result.xml),
64+
)
65+
66+
def test_convert_legacy_question_to_qti_without_hints_produces_no_catalog_info(
67+
self,
68+
):
69+
question_data = {
70+
"type": "multiple_selection",
71+
"assessment_id": "abf45e8fd7f151adb1b3df2d751e945e",
72+
"question": "Which is red?",
73+
"answers": '[{"answer": "Apple", "correct": true, "order": 0}, {"answer": "Sky", "correct": false, "order": 1}]', # noqa
74+
"randomize": False,
75+
}
76+
result = convert_legacy_question_to_qti(question_data)
77+
self.assertNotIn("<qti-catalog-info", result.xml)
78+
4679

4780
def _custom_interaction_item_xml(data_type, path_attr, path_value):
4881
return _item_xml(

contentcuration/contentcuration/tests/utils/test_exercise_creation.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1400,6 +1400,48 @@ def test_basic_qti_exercise_creation(self):
14001400
_normalize_xml(actual_manifest_xml),
14011401
)
14021402

1403+
def _render_single_item_xml(self, assessment_id, hints):
1404+
"""Package a single-question QTI exercise and return that item's XML."""
1405+
item = self._create_assessment_item(
1406+
exercises.SINGLE_SELECTION,
1407+
"What is 2+2?",
1408+
[
1409+
{"answer": "4", "correct": True, "order": 1},
1410+
{"answer": "3", "correct": False, "order": 2},
1411+
],
1412+
hints=hints,
1413+
assessment_id=assessment_id,
1414+
)
1415+
exercise_data = {
1416+
"mastery_model": exercises.M_OF_N,
1417+
"randomize": True,
1418+
"n": 5,
1419+
"m": 3,
1420+
"all_assessment_items": [item.assessment_id],
1421+
"assessment_mapping": {item.assessment_id: exercises.SINGLE_SELECTION},
1422+
}
1423+
self._create_qti_zip(exercise_data)
1424+
exercise_file = self.exercise_node.files.get(preset_id=format_presets.QTI_ZIP)
1425+
zip_file = self._validate_qti_zip_structure(exercise_file)
1426+
return zip_file.read(f"items/{hex_to_qti_id(assessment_id)}.xml").decode(
1427+
"utf-8"
1428+
)
1429+
1430+
def test_qti_exercise_with_hints_produces_catalog_info(self):
1431+
item_xml = self._render_single_item_xml(
1432+
"1234567890abcdef1234567890abcdef",
1433+
[
1434+
{"hint": "Think about pairs.", "order": 1},
1435+
{"hint": "It's 4.", "order": 2},
1436+
],
1437+
)
1438+
self.assertEqual(item_xml.count('support="ext:kolibri-hint"'), 2)
1439+
self.assertLess(item_xml.index("Think about pairs."), item_xml.index("It's 4."))
1440+
1441+
def test_qti_exercise_without_hints_produces_no_catalog_info(self):
1442+
item_xml = self._render_single_item_xml("abcdef1234567890abcdef1234567890", [])
1443+
self.assertNotIn("<qti-catalog-info", item_xml)
1444+
14031445
def test_perseus_question_rejection(self):
14041446
"""Test that Perseus questions are properly rejected"""
14051447
assessment_id = "aaaa1111bbbb2222cccc3333dddd4444"
@@ -1491,7 +1533,7 @@ def test_exercise_with_image(self):
14911533
_normalize_xml(actual_manifest_xml),
14921534
)
14931535

1494-
self.assertEqual(exercise_file.checksum, "8df26b0c7009ae84fe148cceda8e0138")
1536+
self.assertEqual(exercise_file.checksum, "cd5a770d35fa1c25092331ee00f4ce4a")
14951537

14961538
def test_image_resizing(self):
14971539
# Create a base image file
@@ -1580,6 +1622,13 @@ def test_image_resizing(self):
15801622
</qti-simple-choice>
15811623
</qti-choice-interaction>
15821624
</qti-item-body>
1625+
<qti-catalog-info>
1626+
<qti-catalog id="kolibri-hints">
1627+
<qti-card support="ext:kolibri-hint">
1628+
<qti-html-content><p>Hint text</p></qti-html-content>
1629+
</qti-card>
1630+
</qti-catalog>
1631+
</qti-catalog-info>
15831632
<qti-response-processing template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct" />
15841633
</qti-assessment-item>"""
15851634

@@ -1690,7 +1739,7 @@ def test_multiple_question_types_mixed(self):
16901739
_normalize_xml(actual_manifest_xml),
16911740
)
16921741

1693-
self.assertEqual(exercise_file.checksum, "8e488543ef52f0b153553eaf9fb51419")
1742+
self.assertEqual(exercise_file.checksum, "f15370f74b06b59bca6e289fe0e9cb87")
16941743

16951744
def test_unsupported_question_type(self):
16961745
"""Test that unsupported question types raise appropriate errors"""

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ def create_assessment_item(
159159
type=assessment_item.type,
160160
question=processed_data["question"],
161161
answers=processed_data.get("answers", []),
162+
hints=processed_data.get("hints", []),
162163
randomize=processed_data.get("randomize", False),
163164
assessment_id=assessment_item.assessment_id,
164165
title=f"{self.ccnode.title} {len(self.qti_resources) + 1}",

contentcuration/contentcuration/utils/assessment/qti/assessment_item.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from contentcuration.utils.assessment.qti.base import BaseSequence
1313
from contentcuration.utils.assessment.qti.base import QTIBase
1414
from contentcuration.utils.assessment.qti.base import TextType
15+
from contentcuration.utils.assessment.qti.catalog import CatalogInfo
1516
from contentcuration.utils.assessment.qti.constants import BaseType
1617
from contentcuration.utils.assessment.qti.constants import Cardinality
1718
from contentcuration.utils.assessment.qti.constants import ExternalScored
@@ -234,4 +235,5 @@ class AssessmentItem(QTIBase):
234235
response_declaration: List[ResponseDeclaration] = Field(default_factory=list)
235236
outcome_declaration: List[OutcomeDeclaration] = Field(default_factory=list)
236237
item_body: Optional[ItemBody] = None
238+
catalog_info: Optional[CatalogInfo] = None
237239
response_processing: Optional[ResponseProcessing] = None
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from typing import Annotated
2+
from typing import List
3+
from typing import Optional
4+
from typing import Union
5+
6+
from annotated_types import Len
7+
from pydantic import Field
8+
9+
from contentcuration.utils.assessment.qti.base import QTIBase
10+
from contentcuration.utils.assessment.qti.html import FlowContent
11+
from contentcuration.utils.assessment.qti.mathml import Math
12+
13+
14+
KOLIBRI_HINT_SUPPORT = "ext:kolibri-hint"
15+
16+
17+
class HtmlContent(QTIBase):
18+
"""Dormant HTML content carried inside a qti-card, per the qti-catalog-info spec."""
19+
20+
children: List[Union[Math, FlowContent]] = Field(default_factory=list)
21+
22+
23+
class Card(QTIBase):
24+
"""A single support-tagged content card within a qti-catalog."""
25+
26+
support: str = KOLIBRI_HINT_SUPPORT
27+
html_content: Optional[HtmlContent] = None
28+
29+
30+
class Catalog(QTIBase):
31+
"""A named collection of cards for a specific support/feature."""
32+
33+
id_: str
34+
card: Annotated[List[Card], Len(min_length=1)]
35+
36+
37+
class CatalogInfo(QTIBase):
38+
"""Dormant, non-delivered catalog content attached to a qti-assessment-item."""
39+
40+
catalog: Annotated[List[Catalog], Len(min_length=1)]

0 commit comments

Comments
 (0)