From 5e9ac73629c32ba10112aa8aa32ff0e6e7caf009 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Thu, 9 Jul 2026 14:37:30 -0700 Subject: [PATCH] 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) --- .../tests/utils/qti/test_assessment_items.py | 37 ++++++++ .../tests/utils/qti/test_catalog.py | 54 ++++++++++++ .../tests/utils/qti/test_convert.py | 88 +++++++++++++++++++ .../tests/utils/qti/test_ingest.py | 33 +++++++ .../tests/utils/test_exercise_creation.py | 53 ++++++++++- .../utils/assessment/qti/archive.py | 1 + .../utils/assessment/qti/assessment_item.py | 2 + .../utils/assessment/qti/catalog.py | 40 +++++++++ .../utils/assessment/qti/convert.py | 43 +++++++++ .../utils/assessment/qti/ingest.py | 6 ++ 10 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 contentcuration/contentcuration/tests/utils/qti/test_catalog.py create mode 100644 contentcuration/contentcuration/utils/assessment/qti/catalog.py diff --git a/contentcuration/contentcuration/tests/utils/qti/test_assessment_items.py b/contentcuration/contentcuration/tests/utils/qti/test_assessment_items.py index 6bf2f71e51..cdcb3f5cd2 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_assessment_items.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_assessment_items.py @@ -10,6 +10,10 @@ from contentcuration.utils.assessment.qti.assessment_item import ResponseDeclaration from contentcuration.utils.assessment.qti.assessment_item import ResponseProcessing from contentcuration.utils.assessment.qti.assessment_item import Value +from contentcuration.utils.assessment.qti.catalog import Card +from contentcuration.utils.assessment.qti.catalog import Catalog +from contentcuration.utils.assessment.qti.catalog import CatalogInfo +from contentcuration.utils.assessment.qti.catalog import HtmlContent from contentcuration.utils.assessment.qti.constants import BaseType from contentcuration.utils.assessment.qti.constants import Cardinality from contentcuration.utils.assessment.qti.html import Blockquote @@ -31,6 +35,39 @@ class QTIAssessmentItemTests(unittest.TestCase): + def test_assessment_item_with_catalog_info_orders_between_item_body_and_response_processing( + self, + ): + item_body = ItemBody(children=[P(children=["Question."])]) + response_processing = ResponseProcessing( + template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct" + ) + catalog_info = CatalogInfo( + catalog=[ + Catalog( + id_="kolibri-hints", + card=[ + Card(html_content=HtmlContent(children=[P(children=["Hint."])])) + ], + ) + ] + ) + + assessment_item = AssessmentItem( + identifier="item1", + title="Test", + language="en-US", + item_body=item_body, + catalog_info=catalog_info, + response_processing=response_processing, + ) + + xml = assessment_item.to_xml_string() + self.assertLess(xml.index(""), xml.index("")) + self.assertLess( + xml.index(""), xml.index("

First hint.

", + ) + + def test_card_uses_kolibri_hint_support_by_default(self): + card = Card(html_content=HtmlContent(children=[P(children=["Hint."])])) + self.assertEqual(card.support, "ext:kolibri-hint") + self.assertEqual( + card.to_xml_string(), + '' + "

Hint.

", + ) + + def test_catalog_to_xml_string(self): + card = Card(html_content=HtmlContent(children=[P(children=["Hint."])])) + catalog = Catalog(id_="kolibri-hints", card=[card]) + self.assertEqual( + catalog.to_xml_string(), + '' + "

Hint.

" + "
", + ) + + def test_catalog_requires_at_least_one_card(self): + with self.assertRaises(ValueError): + Catalog(id_="kolibri-hints", card=[]) + + def test_catalog_info_to_xml_string(self): + card = Card(html_content=HtmlContent(children=[P(children=["Hint."])])) + catalog_info = CatalogInfo(catalog=[Catalog(id_="kolibri-hints", card=[card])]) + self.assertEqual( + catalog_info.to_xml_string(), + "" + '' + "

Hint.

" + "
", + ) + + def test_catalog_info_requires_at_least_one_catalog(self): + with self.assertRaises(ValueError): + CatalogInfo(catalog=[]) diff --git a/contentcuration/contentcuration/tests/utils/qti/test_convert.py b/contentcuration/contentcuration/tests/utils/qti/test_convert.py index 80dd4da9b7..2e3c2f7961 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_convert.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_convert.py @@ -30,6 +30,7 @@ def _make_item( randomize=False, title="Test Question 1", language="en-US", + hints=None, ): return LegacyAssessmentItem( type=type, @@ -39,6 +40,7 @@ def _make_item( assessment_id=assessment_id, title=title, language=language, + hints=hints or [], ) @@ -244,3 +246,89 @@ def test_unsupported_type_raises(self): convert_legacy_assessment_item_to_qti(item) self.assertIn("Unsupported question type", str(ctx.exception)) + + +class CatalogInfoConversionTests(unittest.TestCase): + def _item_with_hints(self, hints, assessment_id="1234567890abcdef1234567890abcdef"): + return _make_item( + type=exercises.SINGLE_SELECTION, + question="What is 2+2?", + answers=[ + {"answer": "4", "correct": True, "order": 1}, + {"answer": "3", "correct": False, "order": 2}, + ], + assessment_id=assessment_id, + hints=hints, + ) + + def test_no_hints_produces_no_catalog_info(self): + item = self._item_with_hints([]) + result = convert_legacy_assessment_item_to_qti(item) + self.assertNotIn(""), result.xml.index("") + ) + self.assertLess( + result.xml.index(""), + result.xml.index("', result.xml) + self.assertIn("images/hint123.png", result.file_dependencies) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_hint_missing_order_key_does_not_raise(self): + # A malformed hint must degrade gracefully rather than raise: an uncaught + # exception here would abort the entire channel's publish, not just this item. + item = self._item_with_hints([{"hint": "Undated hint"}]) + result = convert_legacy_assessment_item_to_qti(item) + self.assertIn("Undated hint", result.xml) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_hint_with_incomparable_order_values_does_not_raise(self): + # A mixed-type "order" (e.g. a string alongside an int, or None) makes + # sorted() raise TypeError - this must fall back to input order rather + # than crash the channel publish. + item = self._item_with_hints( + [{"hint": "First hint", "order": 1}, {"hint": "Second hint", "order": "2"}] + ) + result = convert_legacy_assessment_item_to_qti(item) + self.assertEqual(result.xml.count('support="ext:kolibri-hint"'), 2) + self.assertIn("First hint", result.xml) + self.assertIn("Second hint", result.xml) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_hint_missing_text_key_is_logged_and_skipped(self): + # Graceful-degradation contract for a hint dict with no "hint" value: + # log + skip it, never crash the channel publish. With this the only + # hint, no card survives, so no qti-catalog-info is emitted at all. + item = self._item_with_hints([{"order": 1}]) + result = convert_legacy_assessment_item_to_qti(item) + self.assertNotIn(" + + + +

Hint text

+
+
+
""" @@ -1690,7 +1739,7 @@ def test_multiple_question_types_mixed(self): _normalize_xml(actual_manifest_xml), ) - self.assertEqual(exercise_file.checksum, "8e488543ef52f0b153553eaf9fb51419") + self.assertEqual(exercise_file.checksum, "f15370f74b06b59bca6e289fe0e9cb87") def test_unsupported_question_type(self): """Test that unsupported question types raise appropriate errors""" diff --git a/contentcuration/contentcuration/utils/assessment/qti/archive.py b/contentcuration/contentcuration/utils/assessment/qti/archive.py index 30e8a397f0..dbbce7f65a 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/archive.py +++ b/contentcuration/contentcuration/utils/assessment/qti/archive.py @@ -159,6 +159,7 @@ def create_assessment_item( type=assessment_item.type, question=processed_data["question"], answers=processed_data.get("answers", []), + hints=processed_data.get("hints", []), randomize=processed_data.get("randomize", False), assessment_id=assessment_item.assessment_id, title=f"{self.ccnode.title} {len(self.qti_resources) + 1}", diff --git a/contentcuration/contentcuration/utils/assessment/qti/assessment_item.py b/contentcuration/contentcuration/utils/assessment/qti/assessment_item.py index 830044ae79..54f52e5375 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/assessment_item.py +++ b/contentcuration/contentcuration/utils/assessment/qti/assessment_item.py @@ -12,6 +12,7 @@ from contentcuration.utils.assessment.qti.base import BaseSequence from contentcuration.utils.assessment.qti.base import QTIBase from contentcuration.utils.assessment.qti.base import TextType +from contentcuration.utils.assessment.qti.catalog import CatalogInfo from contentcuration.utils.assessment.qti.constants import BaseType from contentcuration.utils.assessment.qti.constants import Cardinality from contentcuration.utils.assessment.qti.constants import ExternalScored @@ -234,4 +235,5 @@ class AssessmentItem(QTIBase): response_declaration: List[ResponseDeclaration] = Field(default_factory=list) outcome_declaration: List[OutcomeDeclaration] = Field(default_factory=list) item_body: Optional[ItemBody] = None + catalog_info: Optional[CatalogInfo] = None response_processing: Optional[ResponseProcessing] = None diff --git a/contentcuration/contentcuration/utils/assessment/qti/catalog.py b/contentcuration/contentcuration/utils/assessment/qti/catalog.py new file mode 100644 index 0000000000..263eacd2ba --- /dev/null +++ b/contentcuration/contentcuration/utils/assessment/qti/catalog.py @@ -0,0 +1,40 @@ +from typing import Annotated +from typing import List +from typing import Optional +from typing import Union + +from annotated_types import Len +from pydantic import Field + +from contentcuration.utils.assessment.qti.base import QTIBase +from contentcuration.utils.assessment.qti.html import FlowContent +from contentcuration.utils.assessment.qti.mathml import Math + + +KOLIBRI_HINT_SUPPORT = "ext:kolibri-hint" + + +class HtmlContent(QTIBase): + """Dormant HTML content carried inside a qti-card, per the qti-catalog-info spec.""" + + children: List[Union[Math, FlowContent]] = Field(default_factory=list) + + +class Card(QTIBase): + """A single support-tagged content card within a qti-catalog.""" + + support: str = KOLIBRI_HINT_SUPPORT + html_content: Optional[HtmlContent] = None + + +class Catalog(QTIBase): + """A named collection of cards for a specific support/feature.""" + + id_: str + card: Annotated[List[Card], Len(min_length=1)] + + +class CatalogInfo(QTIBase): + """Dormant, non-delivered catalog content attached to a qti-assessment-item.""" + + catalog: Annotated[List[Catalog], Len(min_length=1)] diff --git a/contentcuration/contentcuration/utils/assessment/qti/convert.py b/contentcuration/contentcuration/utils/assessment/qti/convert.py index 43212438ee..e6906b2696 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/convert.py +++ b/contentcuration/contentcuration/utils/assessment/qti/convert.py @@ -1,8 +1,11 @@ import base64 +import logging from dataclasses import dataclass +from dataclasses import field from typing import Any from typing import Dict from typing import List +from typing import Optional from typing import Tuple from le_utils.constants import exercises @@ -16,6 +19,10 @@ from contentcuration.utils.assessment.qti.assessment_item import ResponseProcessing from contentcuration.utils.assessment.qti.assessment_item import Value from contentcuration.utils.assessment.qti.base import ElementTreeBase +from contentcuration.utils.assessment.qti.catalog import Card +from contentcuration.utils.assessment.qti.catalog import Catalog +from contentcuration.utils.assessment.qti.catalog import CatalogInfo +from contentcuration.utils.assessment.qti.catalog import HtmlContent from contentcuration.utils.assessment.qti.constants import BaseType from contentcuration.utils.assessment.qti.constants import Cardinality from contentcuration.utils.assessment.qti.constants import Orientation @@ -58,6 +65,7 @@ class LegacyAssessmentItem: assessment_id: str title: str language: str + hints: List[Dict[str, Any]] = field(default_factory=list) @dataclass(frozen=True) @@ -75,6 +83,40 @@ def _create_html_content_from_text(text: str) -> FlowContentList: return ElementTreeBase.from_string(markup) +def _create_catalog_info(item: LegacyAssessmentItem) -> Optional[CatalogInfo]: + """Build the dormant hint catalog, or None if the item has no usable hints.""" + try: + sorted_hints = sorted(item.hints, key=lambda hint: hint.get("order", 0)) + except TypeError: + # A mixed-type or otherwise incomparable "order" value must not crash + # the channel publish - fall back to input order, same as base.py's + # ExerciseArchiveGenerator._sort_by_order. + logging.warning( + "Unable to sort hints for assessment item %s, leaving unsorted.", + item.assessment_id, + ) + sorted_hints = item.hints + cards = [] + for hint in sorted_hints: + text = hint.get("hint") + if not text or not text.strip(): + # Log + skip rather than crash the channel publish or emit an + # empty card - matches the per-item log+skip preference. + logging.warning( + "Skipping hint with no text for assessment item %s", + item.assessment_id, + ) + continue + cards.append( + Card( + html_content=HtmlContent(children=_create_html_content_from_text(text)) + ) + ) + if not cards: + return None + return CatalogInfo(catalog=[Catalog(id_="kolibri-hints", card=cards)]) + + def _response_declaration( cardinality: Cardinality, base_type: BaseType, correct_values: List[Value] ) -> ResponseDeclaration: @@ -201,6 +243,7 @@ def convert_legacy_assessment_item_to_qti( response_declaration=[response_declaration], outcome_declaration=[outcome_declaration], item_body=item_body, + catalog_info=_create_catalog_info(item), response_processing=response_processing, ) diff --git a/contentcuration/contentcuration/utils/assessment/qti/ingest.py b/contentcuration/contentcuration/utils/assessment/qti/ingest.py index 1a03e641fb..4b89c5e020 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/ingest.py +++ b/contentcuration/contentcuration/utils/assessment/qti/ingest.py @@ -29,10 +29,16 @@ def convert_legacy_question_to_qti(question_data: dict) -> QTIConversionResult: if isinstance(answer.get("answer"), str): answer["answer"] = strip_content_storage_placeholder(answer["answer"]) + hints = json.loads(question_data.get("hints") or "[]") + for hint in hints: + if isinstance(hint.get("hint"), str): + hint["hint"] = strip_content_storage_placeholder(hint["hint"]) + item = LegacyAssessmentItem( type=question_data["type"], question=strip_content_storage_placeholder(question_data.get("question") or ""), answers=answers, + hints=hints, randomize=question_data.get("randomize") or False, assessment_id=question_data["assessment_id"], title=question_data.get("assessment_id"),