Skip to content

Read and show Numeric answers in the exercise language - #6282

Open
rtibblesbot wants to merge 3 commits into
learningequality:unstablefrom
rtibblesbot:issue-6198-22d404
Open

rtibblesbot wants to merge 3 commits into
learningequality:unstablefrom
rtibblesbot:issue-6198-22d404

Conversation

@rtibblesbot

@rtibblesbot rtibblesbot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Numeric answers are read and shown in the exercise language (fallback: channel, then UI), stored as xsd:double
  • Reformatted on blur and language change; laid out left-to-right
  • ASCII digits typed into an answer in the language's own digits read as those digits (Persian ۷٫۵ + 1 stores 7.51)
  • Invalid-number hint uses the language's examples

References

Fixes #6198. See #6150. Vendored from: kolibri numeralNormalization.js

Reviewer guidance

State Before After
fr, stored 1234.5 and 30 Before fr view After fr view
ar-EG, same item Before ar-EG view After ar-EG view
fr, typing 1,5 (stores 1.5) Before fr edit After fr edit

Seeded through /api/sync/:

  • Opening a non-xsd:double answer sends one change; a valid one, none.
  • Numeric → Text entry → Numeric keeps single cardinality and a qti-mapping.

Open risks:

  • Comma-decimal languages read . before exactly three digits as grouping: German 1.234 → 1234.
  • After a language change, unedited answers save as stored (German 1,2).
  • Stored non-xsd:double answers stay flagged until opened.

QA team: Numeric answers use the exercise language's number format. Arabic exercises and language changes have the most risk.

  1. French: In a Français (fr) exercise, give a Numeric question the answers 1.50 and +1,5. Press Tab. The answers show 1,50 and 1,5. Open the exercise again. The answers do not change.
  2. Language change: In an English (en) exercise, give a Numeric question the answer 1.50. In Details, set Language to Français (fr). The answer shows 1,50.
  3. Arabic: In an العربية (ar) exercise, give a Numeric question the answers -0.5 and 0.5. All answers align left, with the minus on the left. Set Response type to Text entry. The answers keep their digits.
  4. ASCII digits: In a فارسی (fa) exercise, give a Numeric question the answer 7.5. Type 1 at the end on an ASCII keyboard. Press Tab. The answer shows ۷٫۵۱. No alert shows. Do the same in a বাংলা (bn) exercise. The answer shows ৭.৫১.
  5. Side panel: Tick Show answers. The answers show in the exercise language.

Look out for:

  • Close a Numeric question without edits. Look out for "Saving...".

In Chrome, العربية (ar) exercises show Latin digits.

Evidence

Numeric answers in an Arabic (ar-EG) exercise

Step Screenshot
0.5 added: minus on the left, all fields left-aligned 0.5 added: minus on the left, all fields left-aligned
Reopened after save: unchanged Reopened after save: unchanged
Text entry: same digits, hyphen minus Text entry: same digits, hyphen minus

Edit Numeric answers with ASCII digits in native-digit exercises

Step Screenshot
Persian, 1 appended: ۷٫۵۱, no alert Persian, 1 appended: ۷٫۵۱, no alert
Persian, 1 prepended: ۱۷٫۵ Persian, 1 prepended: ۱۷٫۵
Bengali, 1 appended: ৭.৫১ Bengali, 1 appended: ৭.৫১
Bengali, 1 prepended: ১৭.৫ Bengali, 1 prepended: ১৭.৫
English, 1٣: invalid-number alert English, 1٣: invalid-number alert
More captures (9)
Step Screenshot
ar-EG, 1 appended: ٧٫٥١ ar-EG, 1 appended
ar-EG, 1 prepended: ١٧٫٥ ar-EG, 1 prepended
Persian, reopened: ۱۷٫۵ Persian, reopened
ar-EG, before the fix: minus right of the number Before the fix: minus right of the number
ar-EG, before the fix: 0.5 added Before the fix, 0.5 added
French, after Tab: no exponent After Tab: no exponent
French, reopened: 1,50, 0.5 French, reopened: 1,50, 0.5
Switched to French: 1,50, -0, 0,0000001 French: 1,50, -0, 0,0000001
Back to English: as stored Back to English: as stored

Unchanged: Text entry, unset and French language.

AI usage

Claude Code planned and implemented the change from the issue's rules. Verified with the QTIEditor and channelEdit Jest suites, pre-commit, and screenshots of the editor in a headless browser.

🤖 Generated with Claude Code

Deviations from the issue spec

Criterion Why not
Stored answers display in the language's own digits Display uses Intl's default digits: ar-EG shows ٣٠, but Chrome's ar and ur show Latin digits

Written by rtibblesbot, an LLM-based coding agent.

@rtibblesbot

🟡 Waiting for feedback

Last updated: 2026-10-09 03:43 UTC

@rtibblesbot
rtibblesbot marked this pull request as ready for review October 3, 2026 03:57
@rtibblesbot
rtibblesbot requested review from a team and leq-bot October 3, 2026 03:57
@rtibbles rtibbles self-assigned this Oct 4, 2026
...options,
// An unedited answer goes back as stored, so opening it in a language that reads it
// differently (German `1,2`) doesn't rewrite it.
storedValue: id => (uneditedIds.has(id) ? storedById.get(id).stored : undefined),

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.

blocking: storedValue treats every answer as stored, including ones stored under a string declaration. Repro in a French exercise: make a Text entry question with the answer 1,5 and save it. Then switch it to Numeric. The editor shows 1,5 with "Must be a valid number (e.g. 12; 0,5; -3,14)". The message contradicts itself, since its own example has the same shape. buildXML also writes <qti-value>1,5</qti-value> into the float declaration.

The cause: uneditedIds includes the text answer, so storedValue returns the raw 1,5 and skips readLocaleNumber. The storedErrors check at line 67 then flags it because it isn't an xsd:double.

The reasoning in the comment at line 58 holds for answers already stored as numbers. A Text entry answer was never a stored number, though. Could you limit uneditedIds and storedErrors to answers parsed from a base-type="float" declaration? Then a switched answer is read in the exercise language like a typed one, and 1,5 stores 1.5.

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.

Let's drop all this storedValue overhead. Users must always see the actual stored value; e.g., if the user wrote "123.456,78" on blur, they should see "123456,78"; also, on language change, change from text entry to numeric, etc.

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.

Dropped storedValue, storedErrors and the stored/shown swap (0c2d8cd). A Numeric answer now shows its stored value in the exercise language:

  • On open: the stored value, formatted.
  • On switching to Numeric and on leaving an answer: read in the language, then formatted (123.456,78 → 123456,78 in German).
  • On a language change: the editor already remounts and re-parses the stored XML.

Your French repro now shows 1,5, no error, and stores 1.5 (captures). uneditedIds/storedById/storedValue were the only stored-vs-shown special cases; all removed.

One consequence: a legacy float answer stored as 1,5 reads fine in French, so its card in view mode no longer shows Incomplete; opening it rewrites it as 1.5. I dropped the four QTIItemEditor tests asserting that indicator. Should view mode still flag stored answers that aren't xsd:double?

// Text that can't be read is stored as typed. Headless validation passes it when it is an
// xsd:double (German `1.5`), though the editor rejects it (#6150).
baseType === BaseType.FLOAT
? (storedValue?.(a.id) ?? readLocaleNumber(a.value, language) ?? a.value)

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.

suggestion: You list this as an open question, so here's what I found. In German, 1.5 gets INVALID_NUMERIC_VALUE in the editor. It's written as 1.5, though, so validateQtiItem passes it and the question doesn't count as incomplete. That's the same split #6150 (and now #6257) closed. When the question is reopened, parse formats the stored 1.5 as 1,5, and the error is gone.

The error only lasts until the editor re-parses, and the stored value is 1.5 either way. Would it be simpler to accept it, as French already does? Dropping the !grouped condition in readLocaleNumber would do that. 1.234 would still read as grouped in German, and 1.5 would read as 1.5. Then the editor, headless validation and the reopened view all agree.

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 (b74a1bf): readLocaleNumber falls back to xsd:double whenever the language can't read the text, so German 1.5 reads as 1.5 and 1.234 as 1234. Searched localeNumbers.js, validation.js and parse.js for other spots where the editor and headless validation disagree on an xsd:double; the !grouped check was the only one. The German, Spanish and Italian cases moved to the "reads xsd:double" test, and de 1.5 joined the headless-agreement table in validateItem.spec.js.

*/
export function exerciseLanguage(node, channel) {
const language = node?.language || channel?.language || currentLanguage;
return Intl.NumberFormat.supportedLocalesOf([language]).length ? language : '';

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.

suggestion: When the exercise's own language isn't supported, this returns '' without trying the channel's language. In Node's full ICU, 94 of the 286 ids in Languages.js aren't supported (ach, nv, dty, …). So a Navajo exercise in a French channel reads answers as xsd:double only, not French. The qaa test locks this in. Was that intended? If not, you could take the first supported candidate:

const candidates = [node?.language, channel?.language, currentLanguage].filter(Boolean);
return candidates.find(l => Intl.NumberFormat.supportedLocalesOf([l]).length) ?? '';

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.

Not intended. Taken as suggested (8ab5361). The qaa test now expects the channel language, plus a case where both fall through to the UI language. exerciseLanguage is the only place the order is decided; ResourcePanel and useAssessmentItems both call it.

// Stored numeric answers are shown formatted in the exercise language. Leaving Numeric puts
// the ones still shown that way back as stored, and returning formats them again; answers
// the author typed stay as typed.
const storedAnswers = textEntryInteractionDescriptor.parse(

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.

suggestion: Two fragile spots here:

  • The storedValue closure on line 24 reads uneditedIds and storedById, but both are declared later, at lines 35 and 60. It only works because useInteraction builds the XML in a lazy computed. Anything that reads bodyXml during setup would throw a TDZ ReferenceError.
  • The declarations are parsed twice, once with language and once without, and the results are paired by index.

Could parse stay language-free? This composable could parse once and set shown = formatLocaleNumber(stored, language) per answer, and the answer ids would line up for free. It would also drop the language option from parse/_extractAnswers/extractNumericAnswers. Display formatting is a concern of the editor, not of reading the XML.

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 (0c2d8cd). parse, _extractAnswers and extractNumericAnswers no longer take a language; useInteraction passes options to buildXML and validate only. The composable formats the parsed answers once, so there's no second parse, no index pairing, and no closure over later declarations.

errorInvalidNumericValue: {
message: 'Must be a valid number (e.g. 12, 0.5, -3.14)',
context: 'Validation error shown when an answer value is not a valid number',
message: 'Must be a valid number (e.g. {integer}; {decimal}; {negative})',

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.

nitpick: Switching to ; avoids clashing with comma decimals, but the English source now reads "e.g. 12; 0.5; -3.14". Quoting each example avoids the clash and keeps normal commas:

errorInvalidNumericValue: {
  message: 'Must be a valid number (e.g. "{integer}", "{decimal}", "{negative}")',
  context:
    'Validation error shown when an answer value is not a valid number. The placeholders are example numbers written the way the exercise language writes them, and may contain commas or periods. Keep each one inside quotation marks, using the quotation marks your language normally uses.',
},

Use double quotes, not single quotes: in ICU ' is the escape character, so '{integer}' would print the literal placeholder.

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.

Taken as suggested (0c2d8cd). It's the only string with number placeholders.

@rtibbles rtibbles assigned AlexVelezLl and unassigned rtibbles Oct 5, 2026
@rtibblesbot

Copy link
Copy Markdown
Contributor Author

French exercise, live editor after 8ab5361:

State Screenshot
Text entry 1,5 switched to Numeric: shows 1,5, no error, stores 1.5 Text entry 1,5 switched to Numeric
1 234,5 typed, before leaving the field Typed 1 234,5
After leaving the field: 1234,5 Shown as 1234,5 after blur
1,2,3: quoted examples Invalid number message with quoted examples
fr-blur.webm

@rtibblesbot
rtibblesbot force-pushed the issue-6198-22d404 branch 2 times, most recently from ddc82ec to 3cf6bf9 Compare October 5, 2026 22:22
@rtibbles rtibbles self-assigned this Oct 6, 2026
@rtibblesbot
rtibblesbot force-pushed the issue-6198-22d404 branch 3 times, most recently from c396c9d to b9fe289 Compare October 7, 2026 18:12

@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.

Slight change of approach to limit the amount of code we need to maintain ourselves.

@@ -0,0 +1,93 @@
import { parseXsdDouble } from './math';

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.

I think we can probably not have to implement this all ourselves here. This package: https://react-aria.adobe.com/internationalized/number/NumberParser provides most of the number parsing that we require (and can even do some additional number formatting too for display). I think we should lean on this, and drop the requirements that it can't meet (like the very strict separator parsing).

This is a well maintained and active library, so I feel confident this will be easier for us to use going forwards. We should also flag this for similar things on the Kolibri side, so that it can be using the same library and reduce internal code needs.

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 in 87d076b: localeNumbers.js now wraps NumberParser / NumberFormatter, and the vendored numerals.js is gone. I searched the branch for other hand-written parsing and found those 2 files; both changed. parseXsdDouble stays, since it checks the stored format.

Dropped requirements:

  • Group separators are accepted anywhere: English 1,23 stores 123, German 1.5 stores 15.
  • Digits: the language's own plus the 6 systems NumberParser detects, instead of every Unicode \p{Nd}.
  • Answers read in a language store String(number), so French 1,50 stores 1.5. A plain xsd:double the language can't read (French 1.5) is still stored as typed.

I drafted an edit to #6198 with these changes, and a Kolibri follow-up for the QTI text entry. It will be linked here once filed.

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.

Tracked in learningequality/kolibri#15387, which already covers this.

@rtibblesbot

Copy link
Copy Markdown
Contributor Author

Updated #6198 as the review asked:

  • Parsing moved to @internationalized/number. Requirements it can't meet are dropped.
  • Group separators accepted anywhere. English 1,23 now stores 123 instead of being rejected.
  • Digits narrowed. Only the language's own digits and the numbering systems NumberParser detects are read, not every Unicode numbering system.

Written by rtibblesbot, an LLM-based coding agent.

@rtibblesbot
rtibblesbot force-pushed the issue-6198-22d404 branch 2 times, most recently from a90c89b to 408b88f Compare October 8, 2026 03:26
Comment on lines +85 to +93
watch(
() => options.language,
(_, oldLanguage) => {
// Answers shown or typed since parsing are written in the old language; the rest as stored.
mapAnswerValues(({ id, value, stored }) =>
untouchedIds.has(id)
? formatLocaleNumber(stored, options.language)
: showStored(value, oldLanguage),
);

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 did we bring back all the stored, untouchedIds, etc. logic that we had previously dropped?

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.

Dropped untouchedIds in b7d2778. A language change now reads each answer as shown in the old language and reshows it in the new one.

stored stays. It only keeps an unedited answer's stored form in buildXML. Without it, opening 1.50, 1E3 or 0012 in French saves 1.5, 1000 or 12, because opening an item syncs any change. The does not save a numeric answer stored as xsd:double on opening tests cover this.

Searched the QTIEditor tree for other per-answer tracking (untouched, stored). The only other match is numericMapKey, the stored use above.

rtibblesbot and others added 3 commits October 8, 2026 20:08
Parsing and formatting come from @internationalized/number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Display uses the language's separators and digits; the XML keeps canonical xsd:double.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Falls back to the channel language, then the UI language.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[QTI] Read and display Numeric answers using the exercise language's number conventions

3 participants