Skip to content

[QTI] Perseus export publishes wrong answers for input questions that aren't plain numbers #6195

Closed

Description

@rtibbles

❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.

Overview

parser.extract_value accepts an input question answer when a number appears anywhere in it, and publishes that number as the correct answer, so learners who enter the intended answer are marked wrong. This affects 22 items in 4 live channels.

Complexity: Low
Target branch: unstable

Context

  • Every parse function in parser.py uses re.search, so it matches a number anywhere in the answer:
    def parse_integer(text):
    match = INTEGER.search(text)
    return match and float(to_en(match.group(1)))
    def parse_decimal(text):
    match = DECIMAL.search(text)
    return match and float(to_en(match.group(1)))
    def parse_fraction(text):
    match = FRACTION.search(text)
    return match and float(parse_integer(match.group(2))) / float(
    parse_integer(match.group(3))
    )
    def parse_mixed_number(text):
    match = MIXED_NUMBER.search(text)
    if match:
    number = parse_integer(match.group(1))
    return (abs(number) + parse_fraction(match.group(3))) * (
    float(number) / float(abs(number))
    )
    return None
    def parse_percentage(text):
    match = PERCENTAGE.search(text)
    return match and extract_value(match.group(1)) / float(100)
    def parse_exponent(text):
    eval_str = None
    match = EXPONENT.search(text)
    if match:
    val1 = extract_value(match.group(2) or match.group(4))
    val2 = extract_value(match.group(5))
    eval_str = val1 and val2 and "{int}e{exp}".format(int=val1, exp=int(val2))
  • The Perseus export replaces each input answer with the result of extract_value:
    def _process_input_answers(self, processed_data):
    """Extract input answer processing logic"""
    non_empty_answers = []
    for answer in processed_data["answers"]:
    answer["answer"] = extract_value(answer["answer"])
    if answer["answer"] or answer["answer"] == 0:
    non_empty_answers.append(answer)
  • Stored answers and what they publish as:
    4\.62   -> 0.62
    249\.6  -> 0.6
    8\.5    -> 0.5
    0\.0025 -> 0.0025
    1:3     -> 1.0
    

The Change

  • extract_value should return a number only when the whole answer is one, and reject anything else (e.g. 1:3) instead of pulling a number out of part of it.
  • A markdown-escaped decimal point should parse as a plain one: 4\.62 reads as 4.62.

How to Get There

  • Create an exercise with an input question whose answer is 4\.62, publish the channel, and open the item's JSON in the published .perseus file: the numeric-input answer value is 0.62.
  • Or call extract_value("4\\.62") directly.

Out of Scope

  • Rewriting the stored \.-escaped answers.
  • Finding which editor or import wrote the escapes.

Acceptance Criteria

General

  • extract_value returns 4.62, 249.6, 8.5 and 0.0025 for 4\.62, 249\.6, 8\.5 and 0\.0025.
  • extract_value returns None for 1:3, 5 apples, and the test_parser.py cases that only parse part of the answer: 1,00,00, 1.-2, 1.2,00, 1/2/123, 12/0, 1.1.23, 1,000e+-3.
  • The other existing test_parser.py cases return the same values as today.

Testing

  • test_parser.py covers escaped decimals and answers with text around a number.

Testing

  • pytest contentcuration/contentcuration/tests/test_parser.py

AI usage

I directed a survey of input question answers on production and chose this issue's scope, including which formats parse and which are rejected. Claude Code wrote and ran the survey scripts, traced the wrong answers to extract_value, and drafted this issue section by section.

Activity

  1. added theissue type on Sep 25, 2026
  2. rtibblesbot commented on Sep 25, 2026

    @rtibblesbot
    Contributor

    Plan: extract_value returns a number only when the whole answer is one, and reads \. as a decimal point.

    • Match the whole answer
    • Read a markdown-escaped decimal point as a plain one
    • Self-review the produced code

    Assumptions — the issue did not settle these; say so if one is wrong.

    • 1,000e+-3 fully matches EXPONENT today, so the AC's None means rejecting e+-; if only partial parses should change, it stays 1.0.
    • Whitespace around the answer is stripped; if padded answers should be rejected, 4.62 drops from the export.
    • Only \. is unescaped; if other escapes should parse, \-5 and 5\% keep dropping from the export.
    • Non-string answers keep returning None; if they should parse, 10 (tests/fixtures/tree.json:96) keeps dropping from the export.
    • QTI export is untouched; if in scope, convert.py:223-236 still publishes 4\.62 as a string answer.

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

    How was this generated?
    • Implemented code following the pre-approved plan using test-driven development (red-green-refactor)
    • Ran the project's full test suite and linter before committing
    • For UI changes: captured live screenshots to verify rendering
    • For backend changes: verified with example API/CLI output
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions