Skip to content

Commit fabd6db

Browse files
rtibblesbotclaude
andcommitted
Add CustomInteraction QTI model and graded Perseus item builder
Introduce a `CustomInteraction` element (`qti-custom-interaction`) and `build_perseus_custom_interaction_item`, which wraps a raw Perseus question in a schema-valid `qti-assessment-item` marked `data-type="perseus"`. The Perseus renderer owns rendering and grading, reporting its result through the RESPONSE variable as a record (fields: `correct` boolean, `simpleAnswer` string, `answerState` object). RESPONSE is declared with record cardinality and no schema, so each field carries its own base-type at runtime and `answerState` need not be stringified. A SCORE outcome plus inline response processing reads the record's `correct` field and sets SCORE to 1/0, so the item grades as a complete QTI question. The response- processing rule/expression models this needs are added alongside, and ResponseDeclaration's base-type is made optional for record cardinality. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 402f22e commit fabd6db

4 files changed

Lines changed: 227 additions & 2 deletions

File tree

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

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@
33

44
from le_utils.constants import exercises
55

6+
from contentcuration.utils.assessment.qti.convert import (
7+
build_perseus_custom_interaction_item,
8+
)
69
from contentcuration.utils.assessment.qti.convert import (
710
convert_legacy_assessment_item_to_qti,
811
)
12+
from contentcuration.utils.assessment.qti.convert import hex_to_qti_id
913
from contentcuration.utils.assessment.qti.convert import LegacyAssessmentItem
14+
from contentcuration.utils.assessment.qti.interaction_types.custom import (
15+
CustomInteraction,
16+
)
1017
from contentcuration.utils.assessment.qti.validation import validate_qti_item
1118

1219

@@ -232,6 +239,65 @@ def test_free_response_with_maths(self):
232239
)
233240

234241

242+
class CustomInteractionTests(unittest.TestCase):
243+
ASSESSMENT_ID = "2b1c3d4e5f60718293a4b5c6d7e8f900"
244+
245+
def _build_item(self):
246+
return build_perseus_custom_interaction_item(
247+
self.ASSESSMENT_ID,
248+
f"perseus/{self.ASSESSMENT_ID}.json",
249+
"Q 1",
250+
"en",
251+
)
252+
253+
def test_custom_interaction_element_and_attributes(self):
254+
interaction = CustomInteraction(
255+
response_identifier="RESPONSE",
256+
data_type="perseus",
257+
data_perseus_path="perseus/abc.json",
258+
)
259+
260+
xml = interaction.to_xml_string()
261+
262+
self.assertEqual(
263+
_normalize_xml(
264+
'<qti-custom-interaction response-identifier="RESPONSE" '
265+
'data-type="perseus" data-perseus-path="perseus/abc.json" />'
266+
),
267+
_normalize_xml(xml),
268+
)
269+
270+
def test_builder_identifier_and_validity(self):
271+
result = self._build_item()
272+
273+
self.assertEqual(result.identifier, hex_to_qti_id(self.ASSESSMENT_ID))
274+
self.assertEqual(result.file_dependencies, [])
275+
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
276+
self.assertIn('data-type="perseus"', result.xml)
277+
self.assertIn(
278+
f'data-perseus-path="perseus/{self.ASSESSMENT_ID}.json"', result.xml
279+
)
280+
281+
def test_builder_grades_from_record_correct_field(self):
282+
"""
283+
The Perseus renderer reports its result through a record RESPONSE, and
284+
the item grades itself off that record's ``correct`` field.
285+
"""
286+
result = self._build_item()
287+
288+
normalized = _normalize_xml(result.xml)
289+
# RESPONSE is a record so it can carry correct/simpleAnswer/answerState.
290+
self.assertIn(
291+
'<qti-response-declaration identifier="RESPONSE" cardinality="record"',
292+
normalized,
293+
)
294+
# SCORE outcome plus response processing that reads the correct field.
295+
self.assertIn('<qti-outcome-declaration identifier="SCORE"', normalized)
296+
self.assertIn('<qti-field-value field-identifier="correct">', normalized)
297+
self.assertIn('<qti-variable identifier="RESPONSE"', normalized)
298+
self.assertIn('<qti-set-outcome-value identifier="SCORE">', normalized)
299+
300+
235301
class UnsupportedTypeConversionTests(unittest.TestCase):
236302
def test_unsupported_type_raises(self):
237303
item = _make_item(

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

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,9 @@ class ResponseDeclaration(QTIBase):
194194

195195
identifier: QTIIdentifier
196196
cardinality: Cardinality
197-
base_type: BaseType
197+
# base-type is optional in the XSD and is omitted for record cardinality,
198+
# whose fields each carry their own base-type at runtime.
199+
base_type: Optional[BaseType] = None
198200
correct_response: Optional[CorrectResponse] = None
199201
mapping: Optional[Mapping] = None
200202
area_mapping: Optional[AreaMapping] = None
@@ -205,6 +207,57 @@ def validate_cardinality_compatibility(self):
205207
return self
206208

207209

210+
class BaseValue(QTIBase):
211+
"""A literal value of a given base-type (``qti-base-value`` expression)."""
212+
213+
base_type: BaseType
214+
value: TextType
215+
216+
217+
class Variable(QTIBase):
218+
"""Look up the value of an item variable (``qti-variable`` expression)."""
219+
220+
identifier: QTIIdentifier
221+
222+
223+
class FieldValue(QTIBase):
224+
"""
225+
Extract a named field from a record-cardinality expression
226+
(``qti-field-value``). Used to read a single field, e.g. ``correct``, out of
227+
a record response variable.
228+
"""
229+
230+
field_identifier: QTIIdentifier
231+
variable: Variable
232+
233+
234+
class SetOutcomeValue(QTIBase):
235+
"""Assign an expression's value to an outcome variable."""
236+
237+
identifier: QTIIdentifier
238+
base_value: BaseValue
239+
240+
241+
class ResponseIf(QTIBase):
242+
"""The 'if' branch of a response condition: a boolean expression plus a rule."""
243+
244+
field_value: FieldValue
245+
set_outcome_value: SetOutcomeValue
246+
247+
248+
class ResponseElse(QTIBase):
249+
"""The 'else' branch of a response condition."""
250+
251+
set_outcome_value: SetOutcomeValue
252+
253+
254+
class ResponseCondition(QTIBase):
255+
"""An if/else response-processing rule (``qti-response-condition``)."""
256+
257+
response_if: ResponseIf
258+
response_else: Optional[ResponseElse] = None
259+
260+
208261
class ResponseProcessing(QTIBase):
209262
"""Represents response processing rules or template reference"""
210263

@@ -213,7 +266,9 @@ class ResponseProcessing(QTIBase):
213266
# Optional URL that resolves to the template - we additionally enforce that this be local
214267
# although this is not required by the QTI spec
215268
template_location: Optional[LocalHrefPath] = None
216-
# rules deliberately not implemented yet
269+
# Inline response-processing rules, used when no standard template can express
270+
# the grading (e.g. reading the correct/incorrect result out of a record).
271+
children: List[ResponseCondition] = Field(default_factory=list)
217272

218273

219274
class AssessmentItem(QTIBase):

contentcuration/contentcuration/utils/assessment/qti/convert.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,19 @@
1212

1313
from contentcuration.utils.assessment.markdown import render_markdown
1414
from contentcuration.utils.assessment.qti.assessment_item import AssessmentItem
15+
from contentcuration.utils.assessment.qti.assessment_item import BaseValue
1516
from contentcuration.utils.assessment.qti.assessment_item import CorrectResponse
17+
from contentcuration.utils.assessment.qti.assessment_item import FieldValue
1618
from contentcuration.utils.assessment.qti.assessment_item import ItemBody
1719
from contentcuration.utils.assessment.qti.assessment_item import OutcomeDeclaration
20+
from contentcuration.utils.assessment.qti.assessment_item import ResponseCondition
1821
from contentcuration.utils.assessment.qti.assessment_item import ResponseDeclaration
22+
from contentcuration.utils.assessment.qti.assessment_item import ResponseElse
23+
from contentcuration.utils.assessment.qti.assessment_item import ResponseIf
1924
from contentcuration.utils.assessment.qti.assessment_item import ResponseProcessing
25+
from contentcuration.utils.assessment.qti.assessment_item import SetOutcomeValue
2026
from contentcuration.utils.assessment.qti.assessment_item import Value
27+
from contentcuration.utils.assessment.qti.assessment_item import Variable
2128
from contentcuration.utils.assessment.qti.base import ElementTreeBase
2229
from contentcuration.utils.assessment.qti.catalog import Card
2330
from contentcuration.utils.assessment.qti.catalog import Catalog
@@ -30,6 +37,9 @@
3037
from contentcuration.utils.assessment.qti.html import Div
3138
from contentcuration.utils.assessment.qti.html import FlowContentList
3239
from contentcuration.utils.assessment.qti.html import P
40+
from contentcuration.utils.assessment.qti.interaction_types.custom import (
41+
CustomInteraction,
42+
)
3343
from contentcuration.utils.assessment.qti.interaction_types.simple import (
3444
ChoiceInteraction,
3545
)
@@ -208,6 +218,86 @@ def _create_text_entry_interaction_and_response(
208218
return interaction, response_declaration
209219

210220

221+
def build_perseus_custom_interaction_item(
222+
assessment_id: str, perseus_path: str, title: str, language: str
223+
) -> QTIConversionResult:
224+
"""
225+
Wrap a raw Perseus question in a schema-valid ``qti-assessment-item`` whose
226+
body is a single ``qti-custom-interaction`` (``data-type="perseus"``).
227+
228+
The host's Perseus renderer owns rendering and grading, but the result is
229+
handled as a complete QTI question. The renderer reports its outcome through
230+
the ``RESPONSE`` variable as a record with fields ``correct`` (boolean),
231+
``simpleAnswer`` (string) and ``answerState`` (an arbitrary object). The
232+
record declaration specifies no schema, so each field carries its own
233+
base-type at runtime and ``answerState`` need not be stringified.
234+
Response processing reads the ``correct`` field and sets
235+
the ``SCORE`` outcome to 1 (correct) or 0 (incorrect) - the standard
236+
correct/incorrect grading, expressed inline because no standard response
237+
processing template can inspect a record field.
238+
239+
The Perseus JSON and its assets are declared as package files by the
240+
generator, not tracked from this XML, so ``file_dependencies`` is empty.
241+
"""
242+
identifier = hex_to_qti_id(assessment_id)
243+
244+
item = AssessmentItem(
245+
identifier=identifier,
246+
title=title,
247+
language=language,
248+
adaptive=False,
249+
time_dependent=False,
250+
response_declaration=[
251+
ResponseDeclaration(
252+
identifier="RESPONSE",
253+
cardinality=Cardinality.RECORD,
254+
)
255+
],
256+
outcome_declaration=[
257+
OutcomeDeclaration(
258+
identifier="SCORE",
259+
cardinality=Cardinality.SINGLE,
260+
base_type=BaseType.FLOAT,
261+
)
262+
],
263+
item_body=ItemBody(
264+
children=[
265+
CustomInteraction(
266+
response_identifier="RESPONSE",
267+
data_type="perseus",
268+
data_perseus_path=perseus_path,
269+
)
270+
]
271+
),
272+
response_processing=ResponseProcessing(
273+
children=[
274+
ResponseCondition(
275+
response_if=ResponseIf(
276+
field_value=FieldValue(
277+
field_identifier="correct",
278+
variable=Variable(identifier="RESPONSE"),
279+
),
280+
set_outcome_value=SetOutcomeValue(
281+
identifier="SCORE",
282+
base_value=BaseValue(base_type=BaseType.FLOAT, value="1"),
283+
),
284+
),
285+
response_else=ResponseElse(
286+
set_outcome_value=SetOutcomeValue(
287+
identifier="SCORE",
288+
base_value=BaseValue(base_type=BaseType.FLOAT, value="0"),
289+
),
290+
),
291+
)
292+
]
293+
),
294+
)
295+
296+
xml = f'<?xml version="1.0" encoding="UTF-8"?>\n{item.to_xml_string()}'
297+
298+
return QTIConversionResult(identifier=identifier, xml=xml, file_dependencies=[])
299+
300+
211301
def convert_legacy_assessment_item_to_qti(
212302
item: LegacyAssessmentItem,
213303
) -> QTIConversionResult:
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from contentcuration.utils.assessment.qti.interaction_types.base import BlockInteraction
2+
3+
4+
class CustomInteraction(BlockInteraction):
5+
"""
6+
A delivery-engine-specific interaction (``qti-custom-interaction``).
7+
8+
Used to embed a raw Perseus question in a QTI package: the host's Perseus
9+
renderer, keyed off ``data-type="perseus"``, owns rendering and grading and
10+
reports correctness back through the QTI response.
11+
"""
12+
13+
data_type: str
14+
data_perseus_path: str

0 commit comments

Comments
 (0)