Repository navigation
fix(json): keep non-ASCII characters in the text of arbitrary JSON and NDJSON elements - #4639
tanishqbaweja wants to merge 2 commits into
Conversation
…d NDJSON elements pretty_json_text() writes the text of the Text elements that partition_json() and partition_ndjson() make from arbitrary JSON. It called json.dumps() with the default ensure_ascii=True, so every non-ASCII character reached Element.text as a JSON escape: "São Paulo" became "S\u00e3o Paulo", Japanese, Chinese or Russian values became runs of six-character escapes, and an emoji became two escapes. The text was unreadable for embeddings, keyword search and LLM context, and longer than the source: several times longer for CJK or Cyrillic text. Write the text with ensure_ascii=False. A lone surrogate, which a JSON escape can hold but UTF-8 cannot encode, is escaped again as before, because element ids are hashed from the UTF-8 bytes of the text. DEL (U+007F) stays escaped too, as json.dumps() escapes it by default, so content that is all ASCII keeps its text and element ids.
cragwolfe
left a comment
There was a problem hiding this comment.
P2 — Preserve NDJSON round trips for Unicode line separators. pretty_json_text() now emits U+0085, U+2028, and U+2029 literally, but partition_ndjson() splits input with str.splitlines() (unstructured/partition/ndjson.py:95), which treats those characters as record boundaries even inside JSON strings. For example, partition an NDJSON record containing "a\u2028b", serialize the resulting element dictionary with json.dumps(..., ensure_ascii=False), then reimport it as NDJSON: the embedded separator splits the serialized element and raises ValueError("Not a valid ndjson"). The earlier formatter kept the inner escape, so this round trip avoided that split. Please split on the supported physical NDJSON delimiters and add round-trip coverage for these separators; preserve any intended CR-only input support.
The change also intentionally changes public text for non-ASCII arbitrary JSON and NDJSON. Reprocessing can change deterministic element IDs, and chunk boundaries and later IDs can change when chunking is enabled. Please document the compatibility and reindexing implications; a maintainer should decide the release and migration treatment. Rehydrated element text does not pass through this formatter.
Reviewed the actual-base diff and surrounding callers and tests with GPT Pro. No local tests or reproductions were run. This comment does not indicate CI or integration readiness.
(authored by codex)
partition_ndjson(), the NDJSON check in detect_filetype() and file_utils.ndjson.loads() split their input with str.splitlines(), which also breaks at U+0085, U+2028 and U+2029. JSON allows those three characters unescaped inside a string, so a record holding one was cut in two: partition_ndjson() raised "Not a valid ndjson", and a .json file of such records was detected as JSON. Arbitrary-JSON element text now keeps these characters as themselves, so NDJSON written from such elements with ensure_ascii=False holds them too, and reading it back hit the same error. Split only at the other line breaks str.splitlines() knows: CRLF, LF, CR, and the control characters VT, FF, FS, GS and RS, none of which JSON allows unescaped inside a string. Text without U+0085, U+2028 or U+2029 is split exactly as before, CR-only input included.
|
Thanks for catching the round trip. Fixed in 747e08e:
|
Summary
Since #4391,
partition_json()andpartition_ndjson()turn arbitrary JSON (anything that isn't serialized Unstructured elements) intoTextelements that hold the pretty-printed JSON. That text comes frompretty_json_text()inunstructured/partition/common/json_partitioning.py, which calledjson.dumps(value, indent=2, sort_keys=True).ensure_asciidefaults toTrue, so every non-ASCII character reachedElement.textas a six-character\uXXXXescape, and a character outside the Basic Multilingual Plane, such as an emoji, as two of them.partition()on a.jsonor.ndjsonfile goes through the same code.The repo's own
example-docs/test_evaluate_files/gold_standard_table_structure/IRS-2023-Form-1095-A.pdf.json, an arbitrary payload forpartition_json(), shows it too: onmain, 19 of its 81 elements hold escapes, such as"3 Policy issuer\u2019s name", and with this PR they read"3 Policy issuer’s name".Japanese, Chinese or Russian JSON becomes runs of escapes in the text that gets embedded, searched and passed to an LLM, and accented Latin words are split by escapes. The text also gets longer and makes more chunks. Continuing the example above:
On
mainthe text holds escapes whether the file stores the UTF-8 characters or escapes. With this PR it holds the characters in both cases, so a file that Python'sjson.dump()saved with its defaultensure_ascii=Truegives the same text as one saved withensure_ascii=False. #4483 keeps valid UTF-8 input exact for both partitioners; for arbitrary JSON,pretty_json_text()then escaped the characters again.pretty_json_text()now callsjson.dumps()withensure_ascii=False. The text is still valid JSON, andjson.loads(element.text)returns the same value as before.A bare
ensure_ascii=Falsewould break one input that works onmain. A JSON escape can hold one half of a UTF-16 surrogate pair on its own ("\ud800"), andjson.loads()turns it into a lone surrogate, which UTF-8 cannot encode. Written out as it is, that character makespartition_json()andpartition_ndjson()raiseUnicodeEncodeError("surrogates not allowed") inElement.id_to_hash(), which hashes the UTF-8 bytes of the text. Sopretty_json_text()writes any surrogate back as the escapejson.dumps()writes by default. Such a payload still partitions, and its other non-ASCII characters are kept.DEL (U+007F) is ASCII, but
json.dumps()escapes it by default (\u007f), sopretty_json_text()keeps that escape too. For content that is all ASCII, the text is exactly whatmainwrites.Serialized elements that are rehydrated are not affected: their text comes from the
textfield, not frompretty_json_text(). A payload with no non-ASCII characters (after its escapes are decoded) gives the same text, element ids and metadata as before. No fixtures or expected outputs change. The arbitrary-JSON example docs that tests partition (arbitrary-records.json,arbitrary-records.ndjson,not-unstructured-payload.jsonandsingle-line-object.json) are pure ASCII. Thegold_standard_table_structurefiles, such as the IRS form above, are only read by the metrics code, withjson.load(). No file undertest_unstructured_ingest/expected-structured-output*comes from a.jsonor.ndjsoninput.NDJSON line breaks
partition_ndjson(), the NDJSON check indetect_filetype()andunstructured.file_utils.ndjson.loads()split their input withstr.splitlines(). Besides LF, CRLF and CR, that also breaks at U+0085 (NEL), U+2028 and U+2029, which JSON allows unescaped inside a string;json.dumps(..., ensure_ascii=False)and JavaScript'sJSON.stringify()write them that way. A record holding one is cut in two. Onmain,partition_ndjson()raisesValueError("Not a valid ndjson")for such a file, and a.jsonfile of two such records is detected as JSON instead of NDJSON.With the text change above, this also broke a round trip (thanks for the review): element text now holds these characters as themselves, so NDJSON written from the element dicts with
json.dumps(..., ensure_ascii=False)holds them raw, and reading it back raised the same error. Onmainthe round trip worked only because the text kept the escape.The second commit adds
split_lines()tounstructured/file_utils/ndjson.py, and all three callers use it. It splits at the other line breaksstr.splitlines()knows (CRLF, LF, CR, and the control characters VT, FF, FS, GS and RS) but not at U+0085, U+2028 or U+2029. JSON allows none of those control characters unescaped inside a string, so splitting at them can't cut a valid record. For text without U+0085, U+2028 or U+2029 it returns exactly whatstr.splitlines()returns (checked on 84,303 random strings built from these characters), so CR-only input and the other line breaks are read as before.Validation
New tests:
test_unstructured/partition/common/test_json_partitioning.py, forpretty_json_text():it_writes_non_ASCII_characters_as_themselves_rather_than_as_escapes: the exact text for Portuguese, Japanese and an emoji.but_it_keeps_a_lone_surrogate_escaped_so_the_text_can_be_encoded_as_UTF_8: the exact text, with the surrogate escaped andSão Paulokept. The text encodes as UTF-8 andjson.loads()returns the original value.and_it_still_escapes_control_characters_and_DEL: a tab and a newline still come out as\tand\n, and DEL as the escapejson.dumps()writes by default.test_unstructured/partition/test_json.py, in a new section after the UTF-8 detection tests:it_keeps_non_ASCII_characters_in_the_text_of_an_arbitrary_payload[False|True]: afile=payload dumped withensure_ascii=Falseor withensure_ascii=Truegives the same exact text.and_it_keeps_them_when_reading_an_arbitrary_payload_from_a_filenameit_partitions_an_arbitrary_payload_holding_a_lone_surrogate_escape: no error, andjson.loads(element.text)returns the original value.test_unstructured/partition/test_ndjson.py, in the same place: the same three checks for NDJSON lines.The 3 tests that pass on
main(the control characters and DEL, and the two lone-surrogate partition tests) guard the escaping this PR keeps. With a bareensure_ascii=Falsein place of this change,but_it_keeps_a_lone_surrogate_escaped_so_the_text_can_be_encoded_as_UTF_8andand_it_still_escapes_control_characters_and_DELfail, and both lone-surrogate partition tests fail with theUnicodeEncodeErrorabove.Other runs on Windows (Python 3.12):
test_json.py,test_ndjson.pyandcommon/test_json_partitioning.py: 11 failed, 190 passed onmainwith the new tests, and 3 failed, 198 passed with this PR. The 3 that fail on both come from this machine. Two tests expect "nested too deeply" from pretty-printing a 5,000-deep array but get "Not a valid json" (or "ndjson"), becausejson.loads()already hits its recursion limit on this Windows build.it_rehydrates_on_a_detect_then_partition_sequence_over_the_same_file_handleneeds libmagic. Callingpretty_json_text()on a 5,000-deep list built in Python raisesValueError("JSON value is nested too deeply to partition")onmainand with this PR.test_unstructured/partition/test_auto.py -k json: 13 passed, 1 xfailed, onmainand with this PR.pretty_json_text(chr(n))for every code point: each one from U+0020 up, other than",\, DEL and the 2,048 surrogates, comes out as itself (1,112,029 in all). The characters below U+0020,",\, DEL and every surrogate (2,083) come out exactly asjson.dumps()writes them by default, so every ASCII character gives the same text as onmain. Each result encodes as UTF-8, andjson.loads()returns the character.example-docs/arbitrary-records.json,arbitrary-records.ndjson,not-unstructured-payload.jsonandsingle-line-object.jsongive identical element dicts (text, ids and metadata) onmainand with this PR.ruff check .andruff format --check .pass (ruff 0.15.10, the version inuv.lock).Second commit (NDJSON line breaks):
test_unstructured/partition/test_ndjson.py:it_keeps_a_line_whole_when_a_string_in_it_holds_a_Unicode_line_separator, for U+0085, U+2028 and U+2029.and_its_elements_round_trip_through_NDJSON_written_without_escapes, for the same three: partition a record holding the separator, write the element dicts withjson.dumps(..., ensure_ascii=False), and partition that NDJSON again.it_still_splits_lines_on_a_lone_carriage_return: passes before and after; it pins CR-only input.test_unstructured/file_utils/test_filetype.py::and_it_classifies_lines_whose_strings_hold_a_Unicode_line_separator_as_NDJSON, for the same three: a.jsonfile of two such records is NDJSON.On the first commit's code these 10 tests give 9 failed, 1 passed (the CR-only one), and with the second commit 10 passed. On
main(910d9f41e) the keep-whole and detection cases fail the same way, and the round trip passes, sincemainkept the escape.test_ndjson.py,test_json.py,common/test_json_partitioning.py,file_utils/test_filetype.pyandtest_unstructured/staging: 36 failed, 657 passed before the second commit, and 27 failed, 666 passed after it. The only difference is the 9 new tests. The 27 fail on this machine either way: libmagic is not installed here, and the two "too deep" tests hit the recursion limit as described above.test_auto.py -k "json or ndjson": 13 passed, 1 xfailed. ruff 0.15.10checkandformat --checkpass on the changed files.Release note (optional)
The
Textelements thatpartition_json(),partition_ndjson()andpartition()make from arbitrary JSON and NDJSON now hold non-ASCII characters as themselves instead of\uXXXXescapes, whether the file stores the characters or escapes. The text is still valid JSON, andjson.loads()of it returns the same value as before. For elements whose content has non-ASCII characters, the text is shorter, so chunk boundaries can move, and the deterministic element ids, which are hashed from the text, change. Elements whose content is all ASCII, and rehydrated serialized elements, are unchanged. A lone surrogate (an escape such as\ud800that is not half of a pair) still comes out as an escape. NDJSON whose strings hold U+0085, U+2028 or U+2029 unescaped is now read instead of raisingValueError("Not a valid ndjson"), and a.jsonfile of such records is detected as NDJSON.Compatibility and reindexing: re-partitioning the same JSON or NDJSON file gives different text, and so different element ids, for every arbitrary-JSON element whose content has non-ASCII characters. With a chunking strategy, chunk boundaries can move, which also changes the ids of later chunks. An index or cache keyed by element id, such as a vector store that upserts by id, should re-ingest those files; otherwise it keeps the old elements next to the new ones. Elements that are all ASCII and rehydrated serialized elements keep their text and ids. How to release and announce this is your call.
Checklist
enhancement,bug, or an appropriate existing label), or ask a maintainer to apply it. This is abugfix; could a maintainer please apply thebuglabel? I can't set labels on this repository.!in the title and explain impact and migration above. The text and ids of arbitrary-JSON elements with non-ASCII content change, as the release note says; I've left the!decision to you.