Skip to content

Commit c44b302

Browse files
committed
fix: tolerate prose around model JSON
1 parent fb3f6e4 commit c44b302

2 files changed

Lines changed: 70 additions & 31 deletions

File tree

pageindex/utils.py

Lines changed: 34 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
# litellm is imported inside the functions that use it; eager import is slow
2121
# and fetches a remote model-cost map.
2222

23+
_logger = logging.getLogger(__name__)
24+
2325
# Backward compatibility: support CHATGPT_API_KEY as alias for OPENAI_API_KEY
2426
if not os.getenv("OPENAI_API_KEY") and os.getenv("CHATGPT_API_KEY"):
2527
os.environ["OPENAI_API_KEY"] = os.getenv("CHATGPT_API_KEY")
@@ -154,39 +156,41 @@ def get_json_content(response):
154156
return json_content
155157

156158

157-
def extract_json(content):
159+
def _decode_embedded_json(content):
160+
"""Return the first JSON object or array embedded in a model response."""
161+
decoder = json.JSONDecoder()
162+
starts = [index for index in (content.find("{"), content.find("[")) if index >= 0]
163+
if not starts:
164+
return None
158165
try:
159-
# First, try to extract JSON enclosed within ```json and ```
160-
start_idx = content.find("```json")
161-
if start_idx != -1:
162-
start_idx += 7 # Adjust index to start after the delimiter
163-
end_idx = content.rfind("```")
164-
json_content = content[start_idx:end_idx].strip()
165-
else:
166-
# If no delimiters, assume entire content could be JSON
167-
json_content = content.strip()
168-
169-
# Clean up common issues that might cause parsing errors
170-
json_content = json_content.replace('None', 'null') # Replace Python None with JSON null
171-
json_content = json_content.replace('\n', ' ').replace('\r', ' ') # Remove newlines
172-
json_content = ' '.join(json_content.split()) # Normalize whitespace
173-
174-
# Attempt to parse and return the JSON object
175-
return json.loads(json_content)
176-
except json.JSONDecodeError as e:
177-
logging.error(f"Failed to extract JSON: {e}")
178-
# Try to clean up the content further if initial parsing fails
179-
try:
180-
# Remove any trailing commas before closing brackets/braces
181-
json_content = json_content.replace(',]', ']').replace(',}', '}')
182-
return json.loads(json_content)
183-
except:
184-
logging.error("Failed to parse JSON even after cleanup")
185-
return {}
186-
except Exception as e:
187-
logging.error(f"Unexpected error while extracting JSON: {e}")
166+
value, _ = decoder.raw_decode(content[min(starts):])
167+
except json.JSONDecodeError:
168+
return None
169+
return value
170+
171+
172+
def extract_json(content):
173+
if not isinstance(content, str):
174+
_logger.error("Failed to extract JSON: response is not a string")
188175
return {}
189176

177+
# Prefer the fenced body, but also support providers that prepend or append prose.
178+
json_content = get_json_content(content)
179+
for candidate in (json_content, content):
180+
parsed = _decode_embedded_json(candidate)
181+
if parsed is not None:
182+
return parsed
183+
184+
# Preserve the legacy repairs for models that emit Python's None or a trailing comma.
185+
repaired = json_content.replace('None', 'null')
186+
repaired = re.sub(r',\s*([}\]])', r'\1', repaired)
187+
parsed = _decode_embedded_json(repaired)
188+
if parsed is not None:
189+
return parsed
190+
191+
_logger.error("Failed to parse JSON from model response")
192+
return {}
193+
190194
def write_node_id(data, node_id=0):
191195
if isinstance(data, dict):
192196
data['node_id'] = str(node_id).zfill(4)
@@ -974,4 +978,3 @@ def print_tree(tree, indent=0):
974978
def print_wrapped(text, width=100):
975979
for line in text.splitlines():
976980
print(textwrap.fill(line, width=width))
977-

tests/test_page_index.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
process_no_toc,
77
process_toc_no_page_numbers,
88
)
9+
from pageindex.utils import extract_json
910

1011

1112
class ProcessTocNoPageNumbersTest(unittest.TestCase):
@@ -65,5 +66,40 @@ def test_secure_doc_text_neutralizes_document_delimiters(self):
6566
self.assertIn("<physical_index_1>", wrapped)
6667

6768

69+
class ExtractJsonTest(unittest.TestCase):
70+
def test_extracts_object_wrapped_in_prose(self):
71+
response = 'Here is the requested result: {"toc_detected": "yes"}. Hope that helps.'
72+
73+
self.assertEqual(extract_json(response), {"toc_detected": "yes"})
74+
75+
def test_extracts_fenced_json_with_braces_in_a_string(self):
76+
response = '''The result is:
77+
```json
78+
{"thinking": "the source includes {braces}", "completed": "yes"}
79+
```
80+
'''
81+
82+
self.assertEqual(
83+
extract_json(response),
84+
{"thinking": "the source includes {braces}", "completed": "yes"},
85+
)
86+
87+
def test_extracts_array_wrapped_in_prose(self):
88+
response = 'Structured output follows: [{"title": "Introduction"}] Thanks.'
89+
90+
self.assertEqual(extract_json(response), [{"title": "Introduction"}])
91+
92+
def test_preserves_legacy_none_and_trailing_comma_repairs(self):
93+
response = '{"toc_detected": None, "details": {"source": "model"},}'
94+
95+
self.assertEqual(
96+
extract_json(response),
97+
{"toc_detected": None, "details": {"source": "model"}},
98+
)
99+
100+
def test_returns_empty_dict_without_json(self):
101+
self.assertEqual(extract_json("I could not produce structured output."), {})
102+
103+
68104
if __name__ == "__main__":
69105
unittest.main()

0 commit comments

Comments
 (0)