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"),