Skip to content

Commit a1ebb2d

Browse files
authored
feat(llm): recover outer JSON span when fence stripping yields non-JSON (#2610)
Grafts the parse-validated fallback from #2557 onto the line-based fence stripper merged in #2563: after stripping, if the candidate is not valid JSON (partial/absent fence, prose-wrapped or truncated output), fall back to the outermost parseable {..}/[..] span. Never returns a worse candidate than the raw content.
1 parent 06ddf04 commit a1ebb2d

2 files changed

Lines changed: 73 additions & 27 deletions

File tree

hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -67,35 +67,68 @@ def __init__(self, message: str, *, retryable: bool = True):
6767
self.retryable = retryable
6868

6969

70+
def _is_json(text: str) -> bool:
71+
"""True if ``text`` parses as a JSON value."""
72+
try:
73+
json.loads(text)
74+
except (json.JSONDecodeError, ValueError):
75+
return False
76+
return True
77+
78+
79+
def _outer_json_span(content: str) -> str | None:
80+
"""Return the outermost ``{...}`` / ``[...]`` span if it parses as JSON, else None.
81+
82+
Fallback for responses where fences are partial/absent or the model wrapped
83+
the JSON in surrounding prose. Only returned when it is valid JSON so callers
84+
never receive a worse candidate than the raw content.
85+
"""
86+
starts = [i for i in (content.find("{"), content.find("[")) if i >= 0]
87+
ends = [i for i in (content.rfind("}"), content.rfind("]")) if i >= 0]
88+
if not starts or not ends:
89+
return None
90+
start, end = min(starts), max(ends)
91+
if end <= start:
92+
return None
93+
candidate = content[start : end + 1].strip()
94+
return candidate if _is_json(candidate) else None
95+
96+
7097
def _strip_code_fences(content: str) -> str:
7198
"""Strip markdown code fences from LLM response if present.
7299
73100
Many LLM providers (MiniMax, some Ollama models, Claude via proxies)
74101
wrap JSON responses in ```json ... ``` fences even when json_object
75-
response format is requested. This strips the fences while preserving
76-
the JSON content inside. Returns the original content unchanged if
77-
no fences are detected.
102+
response format is requested. Fences are detected by line (a closing
103+
``` must sit alone on its line) so triple-backticks *inside* JSON string
104+
values do not truncate the payload. When the stripped candidate is not
105+
valid JSON (partial fence, prose-wrapped output, truncated response), fall
106+
back to the outermost parseable JSON span. Returns the original content
107+
unchanged if no better candidate is found.
78108
"""
79-
if "```" not in content:
80-
return content
81-
lines = content.split("\n")
82-
# Find first line that starts a code fence (``` optionally followed by language)
83-
fence_start = None
84-
for i, line in enumerate(lines):
85-
if line.startswith("```"):
86-
fence_start = i
87-
break
88-
if fence_start is None:
89-
return content
90-
# Find matching closing fence (``` alone or with trailing whitespace)
91-
fence_end = None
92-
for j in range(fence_start + 1, len(lines)):
93-
if lines[j].strip() == "```":
94-
fence_end = j
95-
break
96-
if fence_end is None:
97-
return content
98-
return "\n".join(lines[fence_start + 1 : fence_end]).strip()
109+
candidate = content
110+
if "```" in content:
111+
lines = content.split("\n")
112+
# Find first line that starts a code fence (``` optionally followed by language)
113+
fence_start = next((i for i, line in enumerate(lines) if line.startswith("```")), None)
114+
if fence_start is not None:
115+
# Find matching closing fence (``` alone or with trailing whitespace)
116+
fence_end = next(
117+
(j for j in range(fence_start + 1, len(lines)) if lines[j].strip() == "```"),
118+
None,
119+
)
120+
if fence_end is not None:
121+
candidate = "\n".join(lines[fence_start + 1 : fence_end]).strip()
122+
123+
if _is_json(candidate):
124+
return candidate
125+
126+
# Fence stripping did not yield valid JSON — try to recover the outer JSON span.
127+
span = _outer_json_span(content)
128+
if span is not None:
129+
return span
130+
131+
return candidate
99132

100133

101134
# Reasoning/thinking tags emitted by extended-thinking models. Some providers

hindsight-api-slim/tests/test_strip_code_fences.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Tests for _strip_code_fences helper in OpenAI-compatible LLM provider."""
22

3-
import pytest
3+
import json
44

55
from hindsight_api.engine.providers.openai_compatible_llm import _strip_code_fences
66

@@ -69,12 +69,25 @@ def test_multiline_json(self):
6969
assert '"line2"' in result
7070
assert "```" not in result
7171

72-
def test_malformed_fence_returns_original(self):
73-
"""Malformed fences (missing closing) return something parseable."""
72+
def test_missing_closing_fence_recovers_json(self):
73+
"""A fence with no closing ``` still recovers the JSON via the outer-span fallback."""
7474
content = '```json\n{"facts": []}'
7575
result = _strip_code_fences(content)
76-
# Should attempt to strip and return best effort
76+
assert json.loads(result) == {"facts": []}
77+
78+
def test_prose_wrapped_json_recovered(self):
79+
"""JSON surrounded by prose (no usable fence) is recovered by the fallback."""
80+
content = 'Sure! Here is the result:\n{"facts": [{"what": "x"}]}\nLet me know if that helps.'
81+
result = _strip_code_fences(content)
82+
assert json.loads(result) == {"facts": [{"what": "x"}]}
83+
84+
def test_non_json_fence_left_for_retry(self):
85+
"""A fenced block that is not JSON yields no valid candidate; content is returned unchanged."""
86+
content = "```\nnot json at all\n```"
87+
result = _strip_code_fences(content)
88+
# No parseable JSON anywhere -> caller sees the stripped body (still a str), never crashes.
7789
assert isinstance(result, str)
90+
assert "not json at all" in result
7891

7992
def test_minimax_style_response(self):
8093
"""Real-world MiniMax response format."""

0 commit comments

Comments
 (0)