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
-
.balanced_object(text, start) ⇒ Object
Scan from
starttracking brace depth, ignoring braces inside string literals (honoring backslash escapes), and return the substring up to the matching close brace (or nil if unbalanced). -
.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).
- .fenced_block(text) ⇒ Object
-
.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.. -
.try_parse(candidate) ⇒ Object
nil on failure so the
||chain falls through to the next strategy.
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).
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 |