mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix: ignore brackets inside JSON strings when splitting mixed content (#553)
_extract_json_block() counted raw [ ] { } per line via str.count() to
find where a JSON block ends. Any bracket/brace inside a JSON string
value (e.g. the "]" in {"path": "a]b"}) was counted as structural, so
the running balance hit zero early and the block was cut mid-array.
In ContentRouter._compress_mixed() this fragments one JSON array into
multiple sections: the array is truncated, a non-array fragment gets
mislabeled JSON_ARRAY, and the trailing "]" leaks into the next prose
section — so content is routed to the wrong compressor.
Walk the characters with a small in-string/escape state machine and
only count brackets/braces that are outside string literals. Behavior
is unchanged for JSON without brackets-in-strings.
Regression tests in tests/test_transforms_content_router.py cover both
the helper (_extract_json_block) and the end-to-end split
(split_into_sections). They fail before this change and pass after.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
92075b95af
commit
bdcfc322da
2 changed files with 87 additions and 2 deletions
|
|
@ -658,13 +658,38 @@ def _extract_json_block(lines: list[str], start: int) -> tuple[str | None, int]:
|
|||
bracket_count = 0
|
||||
brace_count = 0
|
||||
json_lines = []
|
||||
in_string = False
|
||||
escaped = False
|
||||
|
||||
for i in range(start, len(lines)):
|
||||
line = lines[i]
|
||||
json_lines.append(line)
|
||||
|
||||
bracket_count += line.count("[") - line.count("]")
|
||||
brace_count += line.count("{") - line.count("}")
|
||||
# Count brackets/braces, but ignore any that appear inside a JSON
|
||||
# string literal — a naive line.count() treats e.g. the "]" in
|
||||
# {"path": "a]b"} as a closing bracket and terminates the block
|
||||
# early, splitting one array across multiple sections.
|
||||
for ch in line:
|
||||
if escaped:
|
||||
escaped = False
|
||||
continue
|
||||
if ch == "\\":
|
||||
if in_string:
|
||||
escaped = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch == "[":
|
||||
bracket_count += 1
|
||||
elif ch == "]":
|
||||
bracket_count -= 1
|
||||
elif ch == "{":
|
||||
brace_count += 1
|
||||
elif ch == "}":
|
||||
brace_count -= 1
|
||||
|
||||
if bracket_count <= 0 and brace_count <= 0 and json_lines:
|
||||
return "\n".join(json_lines), i
|
||||
|
|
|
|||
|
|
@ -171,6 +171,66 @@ def test_mixed_content_section_splitting_and_json_extraction() -> None:
|
|||
assert _extract_json_block(["{", '"a": 1'], 0) == (None, 0)
|
||||
|
||||
|
||||
def test_extract_json_block_ignores_brackets_inside_strings() -> None:
|
||||
"""Brackets/braces inside JSON string values must not end the block early.
|
||||
|
||||
Regression: counting raw ``[``/``]``/``{``/``}`` per line treated the
|
||||
``]`` inside ``{"path": "a]b"}`` as a closing bracket, so the array was
|
||||
truncated mid-way and the remaining rows leaked into later sections.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
lines = [
|
||||
"[",
|
||||
' {"path": "a]b"},',
|
||||
' {"path": "c"}',
|
||||
"]",
|
||||
]
|
||||
block, end_idx = _extract_json_block(lines, 0)
|
||||
assert end_idx == 3
|
||||
assert block is not None
|
||||
parsed = _json.loads(block)
|
||||
assert parsed == [{"path": "a]b"}, {"path": "c"}]
|
||||
|
||||
# Braces inside a string value must likewise be ignored.
|
||||
obj_lines = [
|
||||
"{",
|
||||
' "msg": "use {curly} and [square]",',
|
||||
' "n": 1',
|
||||
"}",
|
||||
]
|
||||
obj_block, obj_end = _extract_json_block(obj_lines, 0)
|
||||
assert obj_end == 3
|
||||
assert obj_block is not None
|
||||
assert _json.loads(obj_block) == {"msg": "use {curly} and [square]", "n": 1}
|
||||
|
||||
|
||||
def test_split_into_sections_keeps_json_array_with_bracket_in_string() -> None:
|
||||
"""A JSON array embedded in prose stays one JSON section, not fragments.
|
||||
|
||||
With the bracket-in-string bug, the array below split into a truncated
|
||||
JSON section plus a stray ``]`` glued onto the trailing prose.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
content = "\n".join(
|
||||
[
|
||||
"prose line here that is long enough to matter",
|
||||
"[",
|
||||
' {"path": "a]b"},',
|
||||
' {"path": "c"}',
|
||||
"]",
|
||||
"trailing prose",
|
||||
]
|
||||
)
|
||||
|
||||
sections = split_into_sections(content)
|
||||
json_sections = [s for s in sections if s.content_type == ContentType.JSON_ARRAY]
|
||||
assert len(json_sections) == 1
|
||||
parsed = _json.loads(json_sections[0].content)
|
||||
assert parsed == [{"path": "a]b"}, {"path": "c"}]
|
||||
|
||||
|
||||
def test_content_router_strategy_and_compress_paths(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
router = ContentRouter(ContentRouterConfig(prefer_code_aware_for_code=False))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue