Skip to content

Render and grade Perseus questions inside mixed QTI packages#15055

Open
rtibblesbot wants to merge 10 commits into
learningequality:developfrom
rtibblesbot:issue-14892-90f17a
Open

Render and grade Perseus questions inside mixed QTI packages#15055
rtibblesbot wants to merge 10 commits into
learningequality:developfrom
rtibblesbot:issue-14892-90f17a

Conversation

@rtibblesbot

@rtibblesbot rtibblesbot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Render Perseus questions (wrapped in a custom interaction) inline in the QTI viewer; previously only native QTI interactions appeared.
  • Grade them through the QTI response, so a mixed exercise displays and scores as one.
  • Resolve plain and web+graphie: images from the package.
  • Round-trip answer state to package-relative form, so no resolved URLs persist into stored answers.

Acceptance criteria

All of #14892's acceptance criteria are met (mirrored here — the issue checkboxes can't be edited from this account):

  • A mixed package renders its Perseus questions and native QTI interactions together in one exercise — CustomInteraction is registered for qti-custom-interaction in AssessmentItem's SafeHTML map alongside the native interactions and delegates to PerseusCustomInteraction.
  • The image-url map is built from the manifest's declared dependencies, without parsing the item JSON — built from qtiResource.files (the parsed <file> deps) filtered to the item's sibling images/ directory.
  • Plain and web+graphie: images resolve from perseus/images/ — via the reused replaceImageUrls.
  • Package paths are matched forgivingly to Perseus's lookup names (e.g. by basename) — getImageUrl basename fallback, exact-match first.
  • The answer swap-out restores original ${☣ LOCALPATH}/… refs — via the reused restoreImageUrls.
  • No resolved URLs persist into stored item or answer data — the RESPONSE record stores the restored answer state, asserted free of blob:/data: URLs.
  • Grading a Perseus question writes correct to the RESPONSE record — on each interaction the wrapper grades the Perseus item and writes {correct, simpleAnswer, answerState} into the RESPONSE record, so it is current when response processing reads it at check time.
  • The resulting SCORE matches the standalone Perseus renderer's correct/incorrect result — inline qti-field-value response processing, asserted SCORE 1/0 in perseusInQti.spec.js.
  • The rendered Perseus question meets the same accessibility standards as native QTI items — choices are named, keyboard-focusable controls (not tabindex="-1"/aria-hidden).
  • QTI Sandbox fixtures are extended to include some representative Perseus items sampled from the Kolibri QA Channel — __fixtures__/perseus/ adds a plain-image and a graphie item.
  • Tests cover rendering with graphie images — injectedItemData.spec.js and PerseusCustomInteraction.spec.js.

References

Closes #14892. Publish counterpart: learningequality/studio#6047. Builds on #11428.

Reviewer guidance

  • kolibri/plugins/qti_viewer/frontend/components/interactions/PerseusCustomInteraction.vue — the reentrancy guard drops interactions emitted while scoring; check it cannot drop a genuine user interaction that arrives mid-check.
  • kolibri/plugins/perseus_viewer/frontend/views/PerseusRendererIndex.vue getImageUrl — check the exact-key match still precedes the basename fallback.
  • kolibri/plugins/qti_viewer/frontend/components/__fixtures__/perseus/index.js — the sandbox mock's blob: object URLs are never revoked; confirm that is acceptable for the dev-only sandbox.

Screenshots

QTI sandbox (/qti_sandbox/<itemId>), Perseus questions rendering inside the QTI viewer:

State Screenshot
Plain image + choices plain
Correct answer → SCORE 1 (record shows correct:true) graded
Graphie image + TeX labels + multi-select graphie

Accessibility (axe) of the rendered Perseus item: 0 violations in the Perseus subtree. The one page-level violation is the sandbox's pre-existing answer-state debug input (.value-input), dev-only and outside this change.

AI usage

Used Claude Code to implement the feature against the pre-approved plan and to diagnose two rendering bugs that browser QA surfaced but the stubbed unit tests missed. Verified with the full qti_viewer and perseus_viewer Jest suites, prek, and manual browser QA of rendering, grading, and keyboard operation.


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?
  • Ran pre-flight CI checks (lint, format, tests) and verified all pass
  • Rebased onto the target branch and resolved any conflicts
  • Reorganized commit history into clean, logical commits
  • Audited the diff to ensure only issue-relevant files are changed
  • Built PR body from the repository's PR template with evidence blocks
@rtibblesbot

🟡 Waiting for feedback

Last updated: 2026-07-23 23:23 UTC

rtibblesbot and others added 3 commits July 22, 2026 21:30
Parse each manifest resource's declared <file> dependencies into
resourcesMap[id].files, and add qtiPackage.getFile(path) to read
arbitrary package files (for asset-URL resolution). QTIViewer now
provides qtiResource and falls back to an ancestor-injected package
or resource when it loaded none of its own, enabling sandbox/test
injection in itemData mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PerseusRendererIndex can now render from an embellished itemData
(raw Perseus JSON string + a package image-URL map + a source id)
instead of opening its own zip, so a Perseus question carried inside a
QTI package resolves its images without setUpPerseusFile.

resolveItemData branches on the embellished shape: it retains an
injected registry entry (no zipFile — the blob/data URLs are owned by
the QTI zip / sandbox), lets replaceImageUrls merge the map in and
substitute URLs, then renders through the existing pipeline.
getImageUrl gains an exact-match-first, basename-fallback lookup so
package paths match Perseus's lookup names forgivingly, while
restoreImageUrls's invert still round-trips the full LOCALPATH ref.
cleanUpPerseusFile null-guards the missing zipFile. Plain object/string
itemData is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Register a PerseusCustomInteraction for qti-custom-interaction elements
marked data-type="perseus". It builds an image-url map from the resource's
declared manifest dependencies (without parsing the item JSON) and drives the
existing Perseus renderer through a nested ContentViewer fed embellished
itemData.

On check, a response-committer registry on AssessmentItem lets the interaction
pull the nested renderer's checkAnswer() result and write {correct, simpleAnswer,
answerState} into the schemaless RESPONSE record before response processing runs,
so the wrapper's inline processing derives SCORE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added DEV: renderers HTML5 apps, videos, exercises, etc. DEV: frontend SIZE: very large labels Jul 23, 2026
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

@rtibbles rtibbles left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some very poor decisions about architecture, levels of abstraction and delegation of responsibilities.

}

describe('QTISandboxPage', () => {
describe('perseusPackageFor', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unnecessary - our sandbox tests are purely to assert behaviour of the sandbox page, not to assert things about the nature of fixtures. This is over testing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the fixture-shape assertions. The sandbox spec now only asserts sandbox-page behaviour (parseAnswerStateInput) — and the perseusPackageFor export it was testing is gone entirely, since the sandbox no longer builds a package (see the QTIViewer thread).

[OrderInteraction.tag]: OrderInteraction,
[InlineChoiceInteraction.tag]: InlineChoiceInteraction,
[InlineChoice.tag]: InlineChoice,
[PerseusCustomInteraction.tag]: PerseusCustomInteraction,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now requires that the PerseusCustomInteraction component is responsible for rendering any custom interactions that appear in the QTI XML. I think it would be better to make a small, general purpose custom interaction component, that then delegates to the perseus component iff the custom interaction has the appropriate attributes.

If not, it should just raise an error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Added a small general-purpose CustomInteraction (registered for qti-custom-interaction) that delegates to PerseusCustomInteraction when data-type="perseus" and raises an error for any other delivery engine. PerseusCustomInteraction is now only responsible for rendering a Perseus item.


const { responses, processResponses } = qtiContext;

// Interactions whose answer is only known at check time (e.g. the Perseus

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels leaky - pretty sure we should be able to handle this internally to the perseus custom interaction component without leaking these details out.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — removed the registerResponseCommitter registry from AssessmentItem entirely. The Perseus interaction now writes its own RESPONSE record as the learner answers (it owns its grading), guarded against the graphie re-render feedback loop. AssessmentItem just runs response processing, which reads the already-current record.

* @param {string} itemId - The selected sandbox item's identifier.
* @returns {{ qtiPackage: object|null, qtiResource: object|null }}
*/
export function perseusPackageFor(itemId) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this need to happen in the sandbox? Wouldn't this be required for the itemData path in any QTIViewer interaction? This seems to be at the wrong level of abstraction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right — it belongs on the itemData path in QTIViewer, not the sandbox. QTIViewer now synthesizes the package/resource from files carried on itemData ({ xml, files }), so any interaction on the itemData path resolves assets the same way. The sandbox just embellishes the itemData it feeds the viewer; perseusPackageFor/the mock-package provide() are gone.

// loaded none of its own (e.g. the sandbox or tests inject a mock
// package in itemData mode). Descendant interactions resolve assets
// through these.
const injectedPackage = inject('qtiPackage', null);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

God no. What are you doing? The QTIViewer is the source of truth here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Dropped the ancestor inject('qtiPackage'/'qtiResource') fallback. QTIViewer is now the sole source of truth: it provides either the package it loaded from the zip, or one it synthesizes from the files carried on itemData — never one injected from above.

@rtibblesbot
rtibblesbot marked this pull request as ready for review July 23, 2026 22:15
rtibblesbot and others added 7 commits July 23, 2026 16:20
…verage

Add a perseus fixture module holding Perseus items sampled from the Kolibri
QA Channel (a plain-image item and a graphie item), each with its wrapper QTI
XML, verbatim Perseus JSON, and assets inlined as data: URLs. The QTI sandbox
builds a mock qtiPackage/qtiResource from the selected fixture so a Perseus
item renders through QTIViewer's injected-fallback path, listed under a new
"Perseus (mixed QTI)" category.

Extend the tests to cover the real image-url mapping end to end: graphie
.svg/-data.json deps resolve from the injected map with web+graphie: refs
preserved and no LOCALPATH token leaking into answer state, and the rendered
Perseus subtree exposes accessible, focusable controls matching native QTI
interactions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Perseus-in-QTI sandbox mock served each inlined asset as its data:
URL. Perseus's markdown image rule runs URLs through simple-markdown's
sanitizeUrl, which rejects the data: protocol as an XSS defence, so a
plain `![](data:…)` image rendered with src=null and never loaded. A
real package serves assets as blob: URLs (kolibri-zip's
ExtractedFile.toUrl), which pass the sanitizer, so mirror that: the mock
now converts the inlined data: URL into a blob: URL, cached per asset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Scoring a Perseus item can itself emit an interaction — a graphie image
re-rendering as it resolves — and a host that re-checks on every
interaction then re-enters the committer synchronously: interaction →
re-check → commit → checkAnswer → interaction → …, overflowing the stack
and dropping the item into the ContentViewer error boundary. Guard the
committer against re-entry and stop forwarding interactions emitted while
it is scoring, so a genuine user interaction still checks exactly once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A rejected buildEmbellished() left the QTI custom interaction spinning
KCircularLoader indefinitely, since embellished stayed null forever. Track
the failure and fall through to an empty container so the item settles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PerseusCustomInteraction captured data-perseus-path once in setup() and ran
the asset build a single time, so when the component instance is reused for a
different item (e.g. sandbox navigation between the two Perseus fixtures) the
previous item's content stayed rendered. Drive the build from a watcher on the
reactive path prop instead, with a token guard so a slower earlier build cannot
resolve after a newer one and clobber it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework the Perseus-in-QTI wiring per review feedback on delegation of
responsibilities and levels of abstraction:

- QTIViewer owns the package. Drop the ancestor-injected qtiPackage/
  qtiResource fallback; instead QTIViewer synthesizes them from the files
  carried on itemData, so it is the single source of truth whether the item
  came from a zip or was injected directly. The sandbox now embellishes the
  itemData it feeds the viewer ({ xml, files }) rather than provide()-ing a
  mock package — asset resolution for the itemData path is a viewer concern,
  not a sandbox one.

- Delegate custom interactions. AssessmentItem registers a small,
  general-purpose CustomInteraction for qti-custom-interaction that delegates
  to PerseusCustomInteraction when data-type="perseus" and raises an error
  otherwise, instead of PerseusCustomInteraction owning every custom
  interaction tag.

- Grade in-place. The Perseus interaction writes its RESPONSE record itself as
  the learner answers (guarded against the graphie re-render feedback loop),
  so AssessmentItem no longer exposes a response-committer registry.

- Drop the over-testing of fixture shape from the sandbox spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cover the reviewer-requested source-of-truth behaviour directly: given object
itemData, QTIViewer synthesizes a package whose getFile serves the item JSON
and each declared asset to the embedded Perseus interaction — with no injected
package in the tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DEV: frontend DEV: renderers HTML5 apps, videos, exercises, etc. SIZE: very large

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[QTI] Render embedded Perseus questions in the QTI viewer

2 participants