From 35f9adb48af5b8ea644aaae3afa48407dc2504cd Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 17 Oct 2026 21:09:15 -0400 Subject: [PATCH] fix(llm): decode JSON escapes in community labels salvaged from a truncated reply When a labeling reply is cut off mid-object, _parse_label_response falls back to a regex that pulls the complete "": "" pairs, but it used the captured string body as-is. Escapes such as \u00e9 and \" then ended up in the community name as literal backslashes, while the same name in a complete reply decoded correctly. Decode each salvaged value as a JSON string body, keeping it unchanged when it is not a valid one. Co-Authored-By: Claude Opus 5.5 --- graphify/llm.py | 15 ++++++++++++++- tests/test_labeling.py | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/graphify/llm.py b/graphify/llm.py index a5ceb9f7d..9cc83267b 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -3518,6 +3518,16 @@ def _community_label_lines(G, communities, gods, max_communities, top_k): return lines, labeled_cids +def _decode_json_string_body(raw: str) -> str: + """Decode the inside of a JSON string literal, or return it unchanged when + it is not a valid one (an invalid escape, a raw control character).""" + try: + decoded = json.loads(f'"{raw}"') + except (json.JSONDecodeError, ValueError): + return raw + return decoded if isinstance(decoded, str) else raw + + def _parse_label_response(text: str, labeled_cids: list[int]) -> dict[int, str]: """Parse the backend's JSON ``{cid: name}`` reply. Raises on non-JSON or a non-object payload; silently ignores cids it didn't name.""" @@ -3542,7 +3552,10 @@ def _parse_label_response(text: str, labeled_cids: list[int]) -> dict[int, str]: # of dropping the entire batch to placeholders. pairs = re.findall(r'"?(-?\d+)"?\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"', cleaned) if pairs: - data = {k: v for k, v in pairs} + # The captured value is still a JSON string body, escapes and all; + # decode it the way json.loads would have for a complete reply, so a + # salvaged `Caf\u00e9 \"Core\"` reads `Café "Core"`, not the raw escapes. + data = {k: _decode_json_string_body(v) for k, v in pairs} else: raise ValueError(f"label response is not parseable JSON: {text[:120]!r}") out: dict[int, str] = {} diff --git a/tests/test_labeling.py b/tests/test_labeling.py index fed61e6e8..532dc1648 100644 --- a/tests/test_labeling.py +++ b/tests/test_labeling.py @@ -455,6 +455,26 @@ def test_label_communities_salvages_truncated_reply(monkeypatch): assert labels[1] == "Community 1" # truncated cid falls back to placeholder +def test_parse_label_response_salvage_decodes_json_escapes(): + """A salvaged pair from a truncated reply decodes its JSON escapes the same + way a complete reply does, instead of keeping literal backslashes.""" + from graphify.llm import _parse_label_response + + truncated = '{"0": "Caf\\u00e9 \\"Core\\" API", "1":' + complete = '{"0": "Caf\\u00e9 \\"Core\\" API"}' + assert _parse_label_response(complete, [0]) == {0: 'Café "Core" API'} + assert _parse_label_response(truncated, [0, 1]) == {0: 'Café "Core" API'} + + +def test_parse_label_response_salvage_keeps_value_with_invalid_escape(): + """A salvaged value that is not a valid JSON string body is kept as it + arrived rather than dropped.""" + from graphify.llm import _parse_label_response + + truncated = '{"0": "C:\\q Paths", "1":' + assert _parse_label_response(truncated, [0, 1]) == {0: "C:\\q Paths"} + + def test_label_communities_accumulates_token_usage(monkeypatch): # #1694: cluster-only mode reported zero labeling cost because token usage # from the naming LLM calls was never accumulated. label_communities now