Skip to content

Commit 8bd2d6e

Browse files
committed
fix(utils): preserve outer boundaries when extracting JSON
1 parent a72ef89 commit 8bd2d6e

2 files changed

Lines changed: 91 additions & 14 deletions

File tree

‎backend/common/utils/utils.py‎

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,25 +53,49 @@ def deepcopy_ignore_extra(src, dest):
5353
return dest
5454

5555

56-
def extract_nested_json(text: str) -> Optional[str]:
56+
def extract_nested_json(text: str) -> str | None:
57+
"""Return the first valid outer JSON object/array, never a failed one's child."""
5758
if not text or not isinstance(text, str):
5859
return None
5960

60-
decoder = json.JSONDecoder(strict=False)
61+
depth = 0
62+
start = 0
63+
in_string = False
64+
escaped = False
6165
for i, char in enumerate(text):
62-
if char in '{[':
63-
try:
64-
_, end = decoder.raw_decode(text, i)
65-
candidate = text[i:end]
66+
if depth == 0:
67+
if char in "{[":
68+
start = i
69+
depth = 1
70+
continue
71+
72+
if in_string:
73+
if escaped:
74+
escaped = False
75+
elif char == "\\":
76+
escaped = True
77+
elif char == '"':
78+
in_string = False
79+
continue
80+
81+
if char == '"':
82+
in_string = True
83+
elif char in "{[":
84+
depth += 1
85+
elif char in "}]":
86+
depth -= 1
87+
if depth == 0:
88+
# Count boundaries only; orjson checks bracket types and syntax.
89+
# Skip an invalid candidate as a whole instead of retrying inside it.
90+
candidate = text[start : i + 1]
6691
try:
6792
orjson.loads(candidate)
68-
return candidate
69-
except Exception:
93+
except orjson.JSONDecodeError:
7094
continue
71-
except Exception:
72-
continue
95+
return candidate
7396
return None
7497

98+
7599
def string_to_numeric_hash(text: str, bits: Optional[int] = 64) -> int:
76100
hash_bytes = hashlib.sha256(text.encode()).digest()
77101
hash_num = int.from_bytes(hash_bytes, byteorder='big')
@@ -287,4 +311,4 @@ def equals_ignore_case(str1: str, *args: str) -> bool:
287311
continue
288312
if str1.casefold() == arg.casefold():
289313
return True
290-
return False
314+
return False

‎backend/tests/test_extract_nested_json.py‎

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import os
22
import sys
3-
import pytest
3+
44
import orjson
5+
import pytest
56

67
# Ensure backend root is in sys.path
78
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
89
if backend_dir not in sys.path:
910
sys.path.insert(0, backend_dir)
1011

11-
from common.utils.utils import extract_nested_json
12+
from common.utils.utils import extract_nested_json # noqa: E402
1213

1314

1415
def test_standard_json_extraction():
@@ -116,6 +117,58 @@ def test_invalid_candidate_before_valid_json():
116117
assert data["sql"] == "SELECT 1"
117118

118119

119-
@pytest.mark.parametrize("invalid_input", [None, "", " ", 123, [], {}, "No json at all here"])
120+
@pytest.mark.parametrize(
121+
"invalid_input", [None, "", " ", 123, [], {}, "No json at all here"]
122+
)
120123
def test_invalid_or_empty_inputs(invalid_input):
121124
assert extract_nested_json(invalid_input) is None
125+
126+
127+
@pytest.mark.parametrize(
128+
"invalid_candidate",
129+
[
130+
'{"tables": [],}',
131+
'{"outer": {"success": true, "sql": "SELECT 0"}, bad}',
132+
'[{"year": 2026, "value": 10},]',
133+
'{"sql": "SELECT \'{}\'", bad}',
134+
'{"tables": [}, "other": {"success": true}}',
135+
'{"value": NaN, "tables": []}',
136+
'{"sql": "SELECT\n1", "tables": []}',
137+
],
138+
)
139+
def test_invalid_outer_candidate_does_not_expose_nested_values(invalid_candidate):
140+
assert extract_nested_json(invalid_candidate) is None
141+
answer = '{"success": true, "sql": "SELECT 1", "tables": ["t"]}'
142+
assert extract_nested_json(invalid_candidate + " then " + answer) == answer
143+
144+
145+
@pytest.mark.parametrize(
146+
"truncated",
147+
[
148+
'{"sql": "SELECT 1", "tables": ["t"]',
149+
'[{"year": 2026, "value": 10},',
150+
'[{"x": 1}',
151+
'{"outer": {"success": true, "sql": "SELECT 1"}',
152+
'{"text": "unterminated [] {}',
153+
],
154+
)
155+
def test_truncated_outer_candidate_is_rejected(truncated):
156+
assert extract_nested_json(truncated) is None
157+
158+
159+
@pytest.mark.parametrize(
160+
"answer",
161+
[
162+
'{"outer": [{"inner": [1, 2]}, []], "empty": {}}',
163+
"{\"sql\": \"SELECT '[', ']', '{', '}'\", \"tables\": []}",
164+
r'{"text": "escaped quote: \" { [", "tail": "ends in slash \\"}',
165+
"{}",
166+
"[]",
167+
],
168+
)
169+
def test_complete_outer_candidate_is_preserved(answer):
170+
assert (
171+
extract_nested_json('Explanation: "answer" ```json\n' + answer + "\n```")
172+
== answer
173+
)
174+
assert extract_nested_json(answer + ' {"next": true}') == answer

0 commit comments

Comments
 (0)