instructure/canvas-lms · info

Failed to unescape value: #

Error message

Failed to unescape value: #{str.inspect} - #{e.message}. Using original value.

What it means

RubricLlmService#unescape_value attempts JSON.parse("\"#{str}\"") to decode escape sequences (\n, \" etc.) in LLM-produced strings. If the string contains malformed escape sequences, JSON::ParserError is rescued and a warning 'Failed to unescape value: ... Using original value.' is logged, returning the original string. The pipeline continues with the un-unescaped value; nothing raises.

Solutions

  1. No action strictly needed — the method already falls back to the original string; check logs if unescaped backslashes end up rendered oddly in saved rubrics.
  2. Sanitize the LLM output before unescape_value (escape lone backslashes: str.gsub(/\\(?!["\\/bfnrtu])/, '\\\\')).
  3. Constrain the model to emit valid JSON (structured output / JSON mode) so escape sequences are well-formed at the source.
  4. If the fallback is unacceptable, raise or mark the field as needing review instead of silently storing the raw value.

Example fix

// before
JSON.parse("\"#{str}\"")
rescue JSON::ParserError => e
  Rails.logger.warn("Failed to unescape value: #{str.inspect} - #{e.message}. Using original value.")
  str.to_s

// after: repair lone backslashes before parsing
repaired = str.to_s.gsub(/\\(?!["\\/bfnrtu])/) { '\\\\' }
JSON.parse("\"#{repaired}\"")
rescue JSON::ParserError => e
  Rails.logger.warn("Failed to unescape value: #{str.inspect} - #{e.message}. Using original value.")
  str.to_s
Defensive patterns

Strategy: fallback

Validate before calling

// pre-validate LLM string before unescape_value
valid = begin
  JSON.parse("\"#{str}\"")
  true
rescue JSON::ParserError
  false
end
Rails.logger.debug("unescape will fall back for: #{str.inspect}") unless valid

Type guard

def unescapable?(str)
  return true if str.nil?
  !str.match?(/\\(?!["\\/bfnrtu])/)
end

Try / catch

// The method already swallows the error; wrap calls if you need the fallback signal:
raw = llm_output.dig('criteria', 0, 'description')
description = str.is_a?(String) ? service.public_unescape_value(raw) : ""

Prevention

When it happens

Trigger: Called from text_to_rubric and text_to_criterion_update on any LLM string field containing invalid JSON escapes — e.g. a literal backslash not part of a valid escape (\', \\x), a stray backslash before a quote, or control characters that JSON cannot parse.

Common situations: LLM emits Windows-style paths (C:\Users\...), LaTeX/math with backslashes, or code snippets inside rubric criteria/ratings descriptions; prompt-injected or free-form text copied into rubric fields; switching models changes escaping behavior.

Understand the failure class

Background: JSON parse error: "Unexpected token" / "not valid JSON" / "failed to parse" — what JSON parsers are really complaining about — this error's family across 45 libraries.

Related errors


AI-assisted analysis of instructure/canvas-lms@1c9f0bb801 (2026-09-15). Data as JSON: /api/errors/97d56f42ed419354. Report an issue: GitHub.

Appendix: source

Thrown at app/services/rubric_llm_service.rb:818

  # Quote a value as a JSON string without the surrounding quotes escaping issues.
  #
  # Example:
  #   escape_value(%{She said "hi"}) # => "\"She said \\\"hi\\\"\""
  #   (and we later strip the outer quotes when re-parsing)
  def escape_value(str)
    return "" if str.nil?

    JSON.generate(str.to_s)[1..-2]
  end

  # Reverse of escape_value – interpret a line value back into plain text.
  # If JSON parsing fails (e.g., malformed escape sequences from LLM), returns the original string.
  def unescape_value(str)
    return "" if str.nil?

    JSON.parse("\"#{str}\"")
  rescue JSON::ParserError => e
    Rails.logger.warn("Failed to unescape value: #{str.inspect} - #{e.message}. Using original value.")
    str.to_s
  end

  # Reserve IDs from existing criteria/ratings to avoid collisions when
  # creating new ones (e.g., mapping _new_* placeholders later).
  def reserve_existing_ids!(criteria_array)
    criteria_array.each do |c|
      cid = (c[:id] || c["id"]).to_s
      @used_ids[cid] = true if cid.present?

      ratings_raw = c[:ratings] || c["ratings"]
      normalize_ratings_array(ratings_raw).each do |r|
        rid = (r[:id] || r["id"]).to_s
        @used_ids[rid] = true if rid.present?
      end
    end
  end

View on GitHub (pinned to 1c9f0bb801)