Module: Locallingo::JsonExtraction

Defined in:
lib/locallingo/json_extraction.rb

Overview

Extracts a JSON object from an LLM response.

Not every provider has a native "JSON-only" mode (Anthropic, unlike OpenAI's response_format: json_object, does not), so a model may wrap its object in a ```json fence or add a sentence of prose around it. This recovers the object from those shapes.

The naive text[/\{.*\}/m] (greedy) is unsafe: a brace anywhere in the surrounding prose — very likely here, since the translation prompts are all about preserving %{placeholder}s the model may echo — extends the captured span past the real object and JSON.parse raises. So we try, in order: the whole string, a fenced block, then a brace-balanced scan from the first { that respects string literals and escapes.

Class Method Summary collapse

Class Method Details

.balanced_object(text, start) ⇒ Object

Scan from start tracking brace depth, ignoring braces inside string literals (honoring backslash escapes), and return the substring up to the matching close brace (or nil if unbalanced).



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/locallingo/json_extraction.rb', line 73

def balanced_object(text, start)
  depth = 0
  in_string = false
  escaped = false

  text[start..].each_char.with_index do |char, index|
    if in_string
      if escaped then escaped = false
      elsif char == "\\" then escaped = true
      elsif char == '"' then in_string = false
      end
      next
    end

    case char
    when '"' then in_string = true
    when "{" then depth += 1
    when "}"
      depth -= 1
      return text[start, index + 1] if depth.zero?
    end
  end

  nil
end

.extract_object(content) ⇒ Object

Returns the parsed Hash, or raises JSON::ParserError if no JSON object can be recovered (or the top-level value is not an object).

Raises:

  • (JSON::ParserError)


24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/locallingo/json_extraction.rb', line 24

def extract_object(content)
  text = content.to_s.strip

  parsed = try_parse(text) ||
           try_parse(fenced_block(text)) ||
           first_balanced_object(text) ||
           JSON.parse(text) # final attempt; raises with a useful message

  # Both callers treat the result as a key->value Hash, so reject a
  # top-level array (or any non-object) here with a clear contract error
  # rather than letting `.keys`/`.map` fail confusingly downstream.
  return parsed if parsed.is_a?(Hash)

  raise JSON::ParserError, "Expected a top-level JSON object, got #{parsed.class}"
end

.fenced_block(text) ⇒ Object



49
50
51
# File 'lib/locallingo/json_extraction.rb', line 49

def fenced_block(text)
  text[/```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```/m, 1]
end

.first_balanced_object(text) ⇒ Object

Try each {` in the text as a candidate object start (prose can contain stray braces — e.g. a `%{name} placeholder echoed before the real JSON), and return the first balanced span that parses as a JSON object.



56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/locallingo/json_extraction.rb', line 56

def first_balanced_object(text)
  offset = text.index("{")

  while offset
    candidate = balanced_object(text, offset)
    parsed = try_parse(candidate) if candidate
    return parsed unless parsed.nil?

    offset = text.index("{", offset + 1)
  end

  nil
end

.try_parse(candidate) ⇒ Object

nil on failure so the || chain falls through to the next strategy.



41
42
43
44
45
46
47
# File 'lib/locallingo/json_extraction.rb', line 41

def try_parse(candidate)
  return nil if candidate.nil?

  JSON.parse(candidate)
rescue JSON::ParserError
  nil
end