Skip to content

Commit ef68de6

Browse files
authored
fix: extract JSON object/array from mixed LLM responses (#138) (#139)
* fix: extract JSON object/array from mixed LLM responses (#138) * style: reformat code using pre-commit hooks * test(utils): add extract_json_content test cases
1 parent 844c272 commit ef68de6

File tree

2 files changed

+310
-6
lines changed

2 files changed

+310
-6
lines changed

src/metis/utils.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,64 @@ def parse_json_output(model_output):
5858

5959

6060
def extract_json_content(model_output):
61+
"""
62+
Extract JSON content from LLM output that may contain explanatory text.
63+
Handles cases like:
64+
- Pure JSON
65+
- JSON wrapped in ```json ... ```
66+
- JSON embedded in explanatory text
67+
"""
6168
cleaned = model_output.strip()
62-
if cleaned.startswith("```json"):
63-
cleaned = cleaned[len("```json") :].strip()
69+
70+
# Remove markdown code blocks first
71+
if "```json" in cleaned:
72+
# Extract content between ```json and ```
73+
start_idx = cleaned.find("```json") + len("```json")
74+
end_idx = cleaned.find("```", start_idx)
75+
if end_idx != -1:
76+
cleaned = cleaned[start_idx:end_idx].strip()
6477
elif cleaned.startswith("```"):
6578
cleaned = cleaned[len("```") :].strip()
66-
if cleaned.endswith("```"):
67-
cleaned = cleaned[: -len("```")].strip()
68-
elif cleaned.endswith("'''"):
69-
cleaned = cleaned[: -len("'''")].strip()
79+
if cleaned.endswith("```"):
80+
cleaned = cleaned[: -len("```")].strip()
81+
82+
# If still not valid JSON, try to extract JSON object/array from text
83+
if not cleaned.startswith("{") and not cleaned.startswith("["):
84+
json_start = -1
85+
86+
# Find first JSON structure (object or array)
87+
for i, char in enumerate(cleaned):
88+
if char == "{" or char == "[":
89+
json_start = i
90+
break
91+
92+
if json_start == -1:
93+
return cleaned
94+
95+
# Find matching closing brace/bracket using stack
96+
stack = []
97+
json_end = -1
98+
99+
for i in range(json_start, len(cleaned)):
100+
char = cleaned[i]
101+
if char == "{" or char == "[":
102+
stack.append(char)
103+
elif char == "}" or char == "]":
104+
if stack:
105+
stack.pop()
106+
if not stack:
107+
json_end = i + 1
108+
break
109+
110+
if json_end != -1:
111+
extracted = cleaned[json_start:json_end]
112+
# Verify it's valid JSON
113+
try:
114+
json.loads(extracted)
115+
return extracted
116+
except json.JSONDecodeError:
117+
pass
118+
70119
return cleaned
71120

72121

tests/test_utils.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import json
2+
3+
from metis.utils import extract_json_content
4+
5+
6+
# ============================================================
7+
# Pure JSON Tests
8+
# ============================================================
9+
def test_extract_json_content_pure_json_object():
10+
"""Extract pure JSON object unchanged."""
11+
result = extract_json_content('{"key": "value"}')
12+
assert result == '{"key": "value"}'
13+
assert json.loads(result) == {"key": "value"}
14+
15+
16+
def test_extract_json_content_pure_json_array():
17+
"""Extract pure JSON array unchanged."""
18+
result = extract_json_content("[1, 2, 3]")
19+
assert result == "[1, 2, 3]"
20+
assert json.loads(result) == [1, 2, 3]
21+
22+
23+
def test_extract_json_content_empty_object():
24+
"""Handle empty JSON object."""
25+
result = extract_json_content("{}")
26+
assert result == "{}"
27+
assert json.loads(result) == {}
28+
29+
30+
def test_extract_json_content_empty_array():
31+
"""Handle empty JSON array."""
32+
result = extract_json_content("[]")
33+
assert result == "[]"
34+
assert json.loads(result) == []
35+
36+
37+
# ============================================================
38+
# Markdown Code Block Tests
39+
# ============================================================
40+
def test_extract_json_content_markdown_json_block():
41+
"""Extract JSON from ```json code blocks."""
42+
input_text = '```json\n{"a": 1}\n```'
43+
result = extract_json_content(input_text)
44+
assert json.loads(result) == {"a": 1}
45+
46+
47+
def test_extract_json_content_markdown_plain_block():
48+
"""Extract JSON from ``` plain code blocks."""
49+
input_text = '```\n{"a": 1}\n```'
50+
result = extract_json_content(input_text)
51+
assert json.loads(result) == {"a": 1}
52+
53+
54+
def test_extract_json_content_markdown_with_whitespace():
55+
"""Handle whitespace inside markdown code blocks."""
56+
input_text = '```json\n {"a": 1} \n```'
57+
result = extract_json_content(input_text)
58+
assert json.loads(result) == {"a": 1}
59+
60+
61+
def test_extract_json_content_markdown_array_block():
62+
"""Extract array from ```json code blocks."""
63+
input_text = "```json\n[1, 2, 3]\n```"
64+
result = extract_json_content(input_text)
65+
assert json.loads(result) == [1, 2, 3]
66+
67+
68+
# ============================================================
69+
# Embedded JSON Tests
70+
# ============================================================
71+
def test_extract_json_content_text_before_json():
72+
"""Extract JSON with preceding text."""
73+
input_text = 'Here is the result:\n{"data": 1}'
74+
result = extract_json_content(input_text)
75+
assert json.loads(result) == {"data": 1}
76+
77+
78+
def test_extract_json_content_text_after_json():
79+
"""JSON at start with following text is returned as-is (starts with {)."""
80+
input_text = '{"data": 1}\nThis is the output.'
81+
result = extract_json_content(input_text)
82+
# When input starts with { or [, function returns cleaned input as-is
83+
# The JSON extraction logic only triggers when JSON is NOT at the start
84+
assert result == input_text
85+
86+
87+
def test_extract_json_content_text_surrounding_json():
88+
"""Extract JSON with text on both sides."""
89+
input_text = 'Result:\n{"data": 1}\nDone.'
90+
result = extract_json_content(input_text)
91+
assert json.loads(result) == {"data": 1}
92+
93+
94+
def test_extract_json_content_embedded_array():
95+
"""Extract array embedded in text."""
96+
input_text = "The array is [1, 2, 3] here."
97+
result = extract_json_content(input_text)
98+
assert json.loads(result) == [1, 2, 3]
99+
100+
101+
def test_extract_json_content_explanation_with_json():
102+
"""Handle LLM-style response with explanation then JSON."""
103+
input_text = """I've analyzed the code and found the following issues:
104+
105+
{"reviews": [{"issue": "Buffer overflow", "severity": "High"}]}
106+
107+
Let me know if you need more details."""
108+
result = extract_json_content(input_text)
109+
parsed = json.loads(result)
110+
assert "reviews" in parsed
111+
assert parsed["reviews"][0]["issue"] == "Buffer overflow"
112+
113+
114+
# ============================================================
115+
# Nested Structure Tests
116+
# ============================================================
117+
def test_extract_json_content_nested_objects():
118+
"""Handle nested JSON objects."""
119+
input_text = '{"outer": {"inner": "value"}}'
120+
result = extract_json_content(input_text)
121+
assert json.loads(result) == {"outer": {"inner": "value"}}
122+
123+
124+
def test_extract_json_content_nested_arrays():
125+
"""Handle nested JSON arrays."""
126+
input_text = "[[1, 2], [3, 4]]"
127+
result = extract_json_content(input_text)
128+
assert json.loads(result) == [[1, 2], [3, 4]]
129+
130+
131+
def test_extract_json_content_mixed_nesting():
132+
"""Handle mixed object and array nesting."""
133+
input_text = '{"arr": [{"k": "v"}]}'
134+
result = extract_json_content(input_text)
135+
assert json.loads(result) == {"arr": [{"k": "v"}]}
136+
137+
138+
def test_extract_json_content_deeply_nested():
139+
"""Handle deeply nested structures."""
140+
input_text = '{"a": {"b": {"c": {"d": [1, 2, 3]}}}}'
141+
result = extract_json_content(input_text)
142+
expected = {"a": {"b": {"c": {"d": [1, 2, 3]}}}}
143+
assert json.loads(result) == expected
144+
145+
146+
# ============================================================
147+
# Edge Case Tests
148+
# ============================================================
149+
def test_extract_json_content_no_json():
150+
"""Return original text when no JSON is present."""
151+
input_text = "Just plain text without any JSON"
152+
result = extract_json_content(input_text)
153+
assert result == input_text
154+
155+
156+
def test_extract_json_content_whitespace_only():
157+
"""Handle whitespace-only input."""
158+
result = extract_json_content(" ")
159+
assert result == ""
160+
161+
162+
def test_extract_json_content_incomplete_json():
163+
"""Handle incomplete/invalid JSON gracefully."""
164+
input_text = '{"key": "value"'
165+
result = extract_json_content(input_text)
166+
# Should return the cleaned input since JSON is invalid
167+
assert result == input_text
168+
169+
170+
def test_extract_json_content_multiple_json_objects():
171+
"""When input starts with JSON, return as-is even with multiple objects."""
172+
input_text = '{"a": 1} {"b": 2}'
173+
result = extract_json_content(input_text)
174+
# Input starts with {, so function returns cleaned input as-is
175+
# It does not attempt to extract only the first JSON object
176+
assert result == input_text
177+
178+
179+
def test_extract_json_content_first_valid_json():
180+
"""Extract first valid JSON structure from text with multiple."""
181+
input_text = 'First: [1, 2] and second: {"x": 3}'
182+
result = extract_json_content(input_text)
183+
# Should extract the first JSON structure found
184+
assert json.loads(result) == [1, 2]
185+
186+
187+
# ============================================================
188+
# Special Character Tests
189+
# ============================================================
190+
def test_extract_json_content_unicode():
191+
"""Handle unicode characters in JSON."""
192+
input_text = '{"msg": "한글 메시지"}'
193+
result = extract_json_content(input_text)
194+
assert json.loads(result) == {"msg": "한글 메시지"}
195+
196+
197+
def test_extract_json_content_escaped_quotes():
198+
"""Handle escaped quotes inside JSON strings."""
199+
input_text = '{"text": "say \\"hello\\""}'
200+
result = extract_json_content(input_text)
201+
assert json.loads(result) == {"text": 'say "hello"'}
202+
203+
204+
def test_extract_json_content_newlines_in_value():
205+
"""Handle newlines inside JSON string values."""
206+
input_text = '{"text": "line1\\nline2"}'
207+
result = extract_json_content(input_text)
208+
assert json.loads(result) == {"text": "line1\nline2"}
209+
210+
211+
def test_extract_json_content_special_chars():
212+
"""Handle special characters in JSON."""
213+
input_text = '{"path": "C:\\\\Users\\\\test", "tab": "a\\tb"}'
214+
result = extract_json_content(input_text)
215+
parsed = json.loads(result)
216+
assert parsed["path"] == "C:\\Users\\test"
217+
assert parsed["tab"] == "a\tb"
218+
219+
220+
# ============================================================
221+
# Real-world LLM Output Tests
222+
# ============================================================
223+
def test_extract_json_content_llm_thinking_then_json():
224+
"""Handle LLM output with thinking/reasoning before JSON."""
225+
input_text = """Let me analyze this code carefully.
226+
227+
Looking at the function, I can identify a potential buffer overflow.
228+
229+
```json
230+
{
231+
"reviews": [
232+
{
233+
"issue": "Buffer overflow in strcpy",
234+
"severity": "High",
235+
"cwe": "CWE-120"
236+
}
237+
]
238+
}
239+
```
240+
241+
This is a serious security issue."""
242+
result = extract_json_content(input_text)
243+
parsed = json.loads(result)
244+
assert parsed["reviews"][0]["cwe"] == "CWE-120"
245+
246+
247+
def test_extract_json_content_json_without_markdown():
248+
"""Handle JSON embedded in plain text without markdown."""
249+
input_text = (
250+
'The analysis result is {"status": "complete", "issues": 0} end of report.'
251+
)
252+
result = extract_json_content(input_text)
253+
parsed = json.loads(result)
254+
assert parsed["status"] == "complete"
255+
assert parsed["issues"] == 0

0 commit comments

Comments
 (0)