Skip to content

Commit 78d32cd

Browse files
authored
fix(retain): merge JSON arrays in append mode to preserve conversation-aware chunking (#2412)
* fix(retain): merge JSON arrays in append mode to preserve conversation-aware chunking When update_mode=append prepends existing document text as a second content item, combined_content is built with "\n".join(...). For conversation-format content (flat JSON arrays of message dicts), this produces "[...]\n[...]" which is not valid JSON. On subsequent append cycles, chunk_text() fails to parse the corrupted original_text. _chunk_jsonl() also rejects it (lines are arrays, not dicts). The text falls through to RecursiveCharacterTextSplitter, which splits on sentence boundaries with no awareness of conversation turn structure. This produces chunks that begin mid-sentence without speaker attribution, causing the extraction LLM to misattribute statements. Fix: after the append-mode block assembles contents_dicts with the existing and new content items, detect when all items are JSON arrays of dicts and merge them into a single flat array. Non-conversation content (plain text, JSONL) is unaffected. close #2409 * Enhance chunking tests for JSON array formats Add tests for chunking newline-joined and merged JSON arrays. * Add test for valid JSON in append mode This test ensures that appending conversation arrays maintains the original_text as a valid flat JSON array after multiple append cycles, preventing degradation of the data structure. * add missing json import to test_retain_append_mode
1 parent fb475cc commit 78d32cd

3 files changed

Lines changed: 175 additions & 0 deletions

File tree

hindsight-api-slim/hindsight_api/engine/retain/orchestrator.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,28 @@ async def retain_batch(
834834
if first.get("tags"):
835835
existing_content["tags"] = first["tags"]
836836
contents_dicts = [existing_content, *contents_dicts]
837+
# Merge JSON arrays to keep original_text valid (#2409).
838+
# Without this, combined_content joins items with "\n", producing
839+
# "[...]\n[...]" which is not valid JSON. On the next append cycle
840+
# chunk_text() fails to parse it and falls through to sentence-
841+
# boundary text splitting, breaking speaker attribution.
842+
try:
843+
_merged = []
844+
for _item in contents_dicts:
845+
_parsed = json.loads(_item.get("content", ""))
846+
if isinstance(_parsed, list) and all(isinstance(_e, dict) for _e in _parsed):
847+
_merged.extend(_parsed)
848+
else:
849+
_merged = None
850+
break
851+
if _merged is not None:
852+
contents_dicts = [{"content": json.dumps(_merged, ensure_ascii=False)}]
853+
if first.get("context"):
854+
contents_dicts[0]["context"] = first["context"]
855+
if first.get("tags"):
856+
contents_dicts[0]["tags"] = first["tags"]
857+
except (json.JSONDecodeError, ValueError, TypeError):
858+
pass
837859
# Rebuild contents list to match
838860
contents = _build_contents(contents_dicts, document_tags)
839861
log_buffer.append(

hindsight-api-slim/tests/test_chunking.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,3 +395,64 @@ def test_rechunk_preserves_one_chunk_id_per_pre_chunk():
395395
chunk_ids.append(f"bank_doc_{global_idx}")
396396

397397
assert len(chunk_ids) == len(set(chunk_ids)), f"duplicate chunk_ids in one batch: {chunk_ids}"
398+
399+
400+
# ---------------------------------------------------------------------------
401+
# Append-mode JSON array merge simulation (issue #2409)
402+
# ---------------------------------------------------------------------------
403+
404+
405+
def test_newline_joined_json_arrays_bypass_conversation_chunking():
406+
"""Newline-joined JSON arrays (the pre-fix append-mode storage format)
407+
fail both the conversation and JSONL detection paths and fall through
408+
to sentence-boundary text splitting.
409+
410+
This test documents the broken state that issue #2409 fixes at the
411+
orchestrator level. chunk_text() itself is not changed; the fix
412+
merges the arrays before they reach chunk_text().
413+
"""
414+
turn1 = json.dumps([{"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi there"}])
415+
turn2 = json.dumps([{"role": "user", "content": "How are you"}, {"role": "assistant", "content": "Fine"}])
416+
corrupted = turn1 + "\n" + turn2
417+
418+
chunks = chunk_text(corrupted, max_chars=80)
419+
420+
# The corrupted format does NOT route through _chunk_conversation.
421+
# At least one chunk will not be a valid JSON array of dicts.
422+
has_non_json_chunk = False
423+
for chunk in chunks:
424+
try:
425+
parsed = json.loads(chunk)
426+
if not (isinstance(parsed, list) and all(isinstance(e, dict) for e in parsed)):
427+
has_non_json_chunk = True
428+
except json.JSONDecodeError:
429+
has_non_json_chunk = True
430+
assert has_non_json_chunk, (
431+
"Newline-joined JSON arrays should NOT produce valid conversation chunks. "
432+
"If this fails, chunk_text() learned to handle the format and the "
433+
"orchestrator-level merge in #2409 may be redundant."
434+
)
435+
436+
437+
def test_merged_json_array_routes_to_conversation_chunking():
438+
"""A properly merged flat JSON array (the post-fix format) routes
439+
through _chunk_conversation and produces chunks that are each valid
440+
JSON arrays of complete message dicts.
441+
"""
442+
messages = [
443+
{"role": "user", "content": "Hello"},
444+
{"role": "assistant", "content": "Hi there"},
445+
{"role": "user", "content": "How are you"},
446+
{"role": "assistant", "content": "Fine, thanks for asking"},
447+
]
448+
text = json.dumps(messages)
449+
450+
chunks = chunk_text(text, max_chars=120)
451+
452+
assert len(chunks) > 1, "Should produce multiple chunks at this budget"
453+
for chunk in chunks:
454+
parsed = json.loads(chunk)
455+
assert isinstance(parsed, list), f"Chunk must be a JSON array: {chunk[:60]}"
456+
assert all(isinstance(e, dict) for e in parsed), f"Every element must be a dict: {chunk[:60]}"
457+
assert all("role" in e for e in parsed), f"Every element must have a role key: {chunk[:60]}"
458+

hindsight-api-slim/tests/test_retain_append_mode.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Tests for retain update_mode='append' — appends new content to existing documents.
33
"""
44

5+
import json
56
import logging
67
from datetime import datetime, timezone
78

@@ -238,3 +239,94 @@ async def test_replace_mode_is_default(memory, request_context):
238239

239240
finally:
240241
await memory.delete_bank(bank_id, request_context=request_context)
242+
243+
244+
@pytest.mark.asyncio
245+
async def test_append_mode_conversation_arrays_produce_valid_json(memory, request_context):
246+
"""When conversation-format JSON arrays are appended, original_text
247+
must remain a valid flat JSON array after multiple append cycles.
248+
249+
Regression test for #2409: without the merge fix, original_text
250+
becomes newline-joined arrays which breaks conversation-aware chunking.
251+
"""
252+
bank_id = f"test_append_conv_{_ts()}"
253+
document_id = "conversation-json-append"
254+
255+
try:
256+
# First retain - JSON conversation array
257+
turn1 = json.dumps([
258+
{"role": "user", "content": "Hello"},
259+
{"role": "assistant", "content": "Hi there"},
260+
])
261+
await memory.retain_batch_async(
262+
bank_id=bank_id,
263+
contents=[
264+
{
265+
"content": turn1,
266+
"context": "conversation",
267+
"document_id": document_id,
268+
}
269+
],
270+
request_context=request_context,
271+
)
272+
273+
# Second retain - append more turns
274+
turn2 = json.dumps([
275+
{"role": "user", "content": "How are you"},
276+
{"role": "assistant", "content": "Doing well"},
277+
])
278+
await memory.retain_batch_async(
279+
bank_id=bank_id,
280+
contents=[
281+
{
282+
"content": turn2,
283+
"context": "conversation",
284+
"document_id": document_id,
285+
"update_mode": "append",
286+
}
287+
],
288+
request_context=request_context,
289+
)
290+
291+
# Verify original_text is valid JSON (not newline-joined arrays)
292+
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
293+
text = doc["original_text"]
294+
295+
parsed = json.loads(text)
296+
assert isinstance(parsed, list), "original_text must be a JSON array"
297+
assert all(isinstance(e, dict) for e in parsed), (
298+
"original_text must be a flat array of dicts, not nested arrays"
299+
)
300+
assert len(parsed) == 4, "Should contain all 4 messages from both retains"
301+
302+
# Third retain - append again, verify no degradation
303+
turn3 = json.dumps([
304+
{"role": "user", "content": "What is new"},
305+
{"role": "assistant", "content": "Not much"},
306+
])
307+
await memory.retain_batch_async(
308+
bank_id=bank_id,
309+
contents=[
310+
{
311+
"content": turn3,
312+
"context": "conversation",
313+
"document_id": document_id,
314+
"update_mode": "append",
315+
}
316+
],
317+
request_context=request_context,
318+
)
319+
320+
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
321+
text = doc["original_text"]
322+
323+
parsed = json.loads(text)
324+
assert isinstance(parsed, list), "original_text must remain a JSON array after 3rd append"
325+
assert all(isinstance(e, dict) for e in parsed), (
326+
"original_text must remain a flat array of dicts after 3rd append"
327+
)
328+
assert len(parsed) == 6, "Should contain all 6 messages from three retains"
329+
330+
finally:
331+
await memory.delete_bank(bank_id, request_context=request_context)
332+

0 commit comments

Comments
 (0)