llmclean 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: llmclean
3
+ Version: 0.1.0
4
+ Summary: Utilities for cleaning and normalizing raw LLM output
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/Tushar-9802/llmclean
7
+ Keywords: llm,cleaning,json,text,ai,output,normalization
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Topic :: Text Processing
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.0; extra == "dev"
22
+
23
+ # llmclean
24
+
25
+ **A zero-dependency Python library for cleaning and normalizing raw LLM output.**
26
+
27
+ LLMs are inconsistent: they wrap JSON in markdown fences, add prose around code, repeat themselves, and produce subtly broken JSON. `llmclean` handles all of that with three focused utilities.
28
+
29
+ ---
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install llmclean
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Quick start
40
+
41
+ ```python
42
+ from llmclean import strip_fences, enforce_json, trim_repetition
43
+
44
+ # Remove ```json ... ``` wrappers
45
+ strip_fences('```json\n{"name": "Alice"}\n```')
46
+ # → '{"name": "Alice"}'
47
+
48
+ # Extract valid JSON from messy output
49
+ enforce_json('Here you go: {"ok": True, "items": [1,2,3,]}')
50
+ # → '{\n "ok": true,\n "items": [1, 2, 3]\n}'
51
+
52
+ # Remove repeated sentences/paragraphs at the end
53
+ trim_repetition("The answer is 42. This is final. This is final.")
54
+ # → 'The answer is 42. This is final.'
55
+ ```
56
+
57
+ For full examples and edge cases see **[USAGE.md](USAGE.md)**.
58
+
59
+ ---
60
+
61
+ ## Functions
62
+
63
+ | Function | What it fixes |
64
+ |---|---|
65
+ | `strip_fences(text)` | Removes ` ```lang ` / ` ``` ` / `~~~` code fences |
66
+ | `enforce_json(text)` | Extracts valid JSON from fences, prose, trailing commas, Python literals, unquoted keys, unclosed brackets |
67
+ | `trim_repetition(text)` | Removes repeated sentences, near-duplicates, and repeated paragraphs from the tail |
68
+
69
+ ---
70
+
71
+ ## Design principles
72
+
73
+ - **Zero dependencies** — pure Python standard library
74
+ - **Never throws** — every function returns the original input if cleaning fails
75
+ - **Non-destructive** — unchanged input when nothing needs cleaning
76
+ - **Composable** — chain freely
77
+
78
+ ```python
79
+ # Full pipeline
80
+ data = enforce_json(trim_repetition(strip_fences(raw_output)))
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Running tests
86
+
87
+ ```bash
88
+ # With pytest
89
+ pip install "llmclean[dev]"
90
+ pytest -v
91
+
92
+ # Without pytest
93
+ python run_tests.py
94
+ ```
95
+
96
+ ---
97
+
98
+ ## License
99
+
100
+ MIT
@@ -0,0 +1,78 @@
1
+ # llmclean
2
+
3
+ **A zero-dependency Python library for cleaning and normalizing raw LLM output.**
4
+
5
+ LLMs are inconsistent: they wrap JSON in markdown fences, add prose around code, repeat themselves, and produce subtly broken JSON. `llmclean` handles all of that with three focused utilities.
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install llmclean
13
+ ```
14
+
15
+ ---
16
+
17
+ ## Quick start
18
+
19
+ ```python
20
+ from llmclean import strip_fences, enforce_json, trim_repetition
21
+
22
+ # Remove ```json ... ``` wrappers
23
+ strip_fences('```json\n{"name": "Alice"}\n```')
24
+ # → '{"name": "Alice"}'
25
+
26
+ # Extract valid JSON from messy output
27
+ enforce_json('Here you go: {"ok": True, "items": [1,2,3,]}')
28
+ # → '{\n "ok": true,\n "items": [1, 2, 3]\n}'
29
+
30
+ # Remove repeated sentences/paragraphs at the end
31
+ trim_repetition("The answer is 42. This is final. This is final.")
32
+ # → 'The answer is 42. This is final.'
33
+ ```
34
+
35
+ For full examples and edge cases see **[USAGE.md](USAGE.md)**.
36
+
37
+ ---
38
+
39
+ ## Functions
40
+
41
+ | Function | What it fixes |
42
+ |---|---|
43
+ | `strip_fences(text)` | Removes ` ```lang ` / ` ``` ` / `~~~` code fences |
44
+ | `enforce_json(text)` | Extracts valid JSON from fences, prose, trailing commas, Python literals, unquoted keys, unclosed brackets |
45
+ | `trim_repetition(text)` | Removes repeated sentences, near-duplicates, and repeated paragraphs from the tail |
46
+
47
+ ---
48
+
49
+ ## Design principles
50
+
51
+ - **Zero dependencies** — pure Python standard library
52
+ - **Never throws** — every function returns the original input if cleaning fails
53
+ - **Non-destructive** — unchanged input when nothing needs cleaning
54
+ - **Composable** — chain freely
55
+
56
+ ```python
57
+ # Full pipeline
58
+ data = enforce_json(trim_repetition(strip_fences(raw_output)))
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Running tests
64
+
65
+ ```bash
66
+ # With pytest
67
+ pip install "llmclean[dev]"
68
+ pytest -v
69
+
70
+ # Without pytest
71
+ python run_tests.py
72
+ ```
73
+
74
+ ---
75
+
76
+ ## License
77
+
78
+ MIT
@@ -0,0 +1,18 @@
1
+ """
2
+ llmclean — utilities for cleaning and normalizing raw LLM output.
3
+
4
+ Quick start::
5
+
6
+ from llmclean import strip_fences, enforce_json, trim_repetition
7
+
8
+ clean = strip_fences(raw_output)
9
+ data = enforce_json(raw_output)
10
+ text = trim_repetition(raw_output)
11
+ """
12
+
13
+ from .fences import strip_fences
14
+ from .json_utils import enforce_json
15
+ from .repetition import trim_repetition
16
+
17
+ __all__ = ["strip_fences", "enforce_json", "trim_repetition"]
18
+ __version__ = "0.1.0"
@@ -0,0 +1,155 @@
1
+ """
2
+ fences.py — strip markdown code fences from LLM output.
3
+
4
+ LLMs frequently wrap their output in fences like:
5
+ ```json
6
+ { "key": "value" }
7
+ ```
8
+
9
+ This module handles:
10
+ - Named fences: ```json ... ```
11
+ - Anonymous fences: ``` ... ```
12
+ - Tilde fences: ~~~ ... ~~~
13
+ - Indented fences: leading whitespace before the backticks
14
+ - Multiple fences in one string (strips all of them)
15
+ - Nested / back-to-back fences
16
+ - Fences with no closing marker (strips opening line, returns rest)
17
+ - Stray language tags left on their own line after stripping
18
+ """
19
+
20
+ import re
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Patterns
24
+ # ---------------------------------------------------------------------------
25
+
26
+ # Matches an opening fence line, e.g. ```json or ``` or ~~~python
27
+ # Group 1: the fence characters (``` or ~~~)
28
+ # Group 2: optional language identifier (may be empty)
29
+ _OPEN_FENCE_RE = re.compile(
30
+ r"^[ \t]*(?P<fence>`{3,}|~{3,})[ \t]*(?P<lang>[a-zA-Z0-9_+\-.]*)[ \t]*$",
31
+ re.MULTILINE,
32
+ )
33
+
34
+ # Matches a closing fence: same or more fence characters on its own line
35
+ _CLOSE_FENCE_RE = re.compile(
36
+ r"^[ \t]*(?P<fence>`{3,}|~{3,})[ \t]*$",
37
+ re.MULTILINE,
38
+ )
39
+
40
+ # After all fences are stripped, clean up lone language-tag lines that were
41
+ # sitting right above the content, e.g. a line that is just "json" or "python"
42
+ # This is intentionally narrow to avoid removing real content.
43
+ _LONE_LANG_TAG_RE = re.compile(
44
+ r"^[ \t]*(?:json|python|javascript|js|typescript|ts|bash|sh|shell|"
45
+ r"html|xml|css|yaml|yml|toml|ini|markdown|md|text|txt|plaintext|"
46
+ r"sql|graphql|rust|go|java|c|cpp|c\+\+|csharp|cs|ruby|rb|php|"
47
+ r"swift|kotlin|scala|r|lua|perl|haskell|elixir|erlang|clojure|"
48
+ r"dart|objc|output|console|log)[ \t]*$",
49
+ re.MULTILINE | re.IGNORECASE,
50
+ )
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Public API
55
+ # ---------------------------------------------------------------------------
56
+
57
+ def strip_fences(text: str) -> str:
58
+ """Remove all markdown/tilde code fences from *text*.
59
+
60
+ Strips every fenced block found, replacing each fence with the raw
61
+ content that was inside it. If no fences are present the original
62
+ text is returned unchanged. The function never raises.
63
+
64
+ Parameters
65
+ ----------
66
+ text:
67
+ Raw LLM output that may contain one or more fenced code blocks.
68
+
69
+ Returns
70
+ -------
71
+ str
72
+ The cleaned text with fence markers removed and inner content
73
+ preserved. Leading/trailing whitespace that was *outside* all
74
+ fences is also stripped.
75
+ """
76
+ if not isinstance(text, str):
77
+ return text # defensive: wrong type → return as-is
78
+
79
+ original = text
80
+
81
+ try:
82
+ result = _strip_all_fences(text)
83
+ # Collapse any excessive blank lines introduced by fence removal
84
+ result = _normalize_blank_lines(result)
85
+ return result
86
+ except Exception:
87
+ # Safety net: never crash the caller
88
+ return original
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Internal helpers
93
+ # ---------------------------------------------------------------------------
94
+
95
+ def _strip_all_fences(text: str) -> str:
96
+ """Iteratively strip fences until no more remain."""
97
+ # We loop because fences can be back-to-back; each pass removes one layer.
98
+ for _ in range(50): # guard against pathological inputs
99
+ new_text = _strip_one_pass(text)
100
+ if new_text == text:
101
+ break
102
+ text = new_text
103
+
104
+ # Clean up orphaned language-tag lines (e.g. a bare "json" line)
105
+ text = _LONE_LANG_TAG_RE.sub("", text)
106
+ return text.strip()
107
+
108
+
109
+ def _strip_one_pass(text: str) -> str:
110
+ """Remove every complete (or unclosed) fence block found in *text*."""
111
+ result_parts: list[str] = []
112
+ cursor = 0
113
+
114
+ for open_match in _OPEN_FENCE_RE.finditer(text):
115
+ open_start = open_match.start()
116
+ open_end = open_match.end()
117
+ fence_chars = open_match.group("fence")
118
+ fence_type = fence_chars[0] # ` or ~
119
+
120
+ # Append everything before this opening fence
121
+ result_parts.append(text[cursor:open_start])
122
+
123
+ # Look for a matching closing fence (same fence character, >= same length)
124
+ close_match = _find_closing_fence(text, open_end, fence_type, len(fence_chars))
125
+
126
+ if close_match:
127
+ # Content between open and close fences
128
+ inner = text[open_end:close_match.start()]
129
+ result_parts.append(inner)
130
+ cursor = close_match.end()
131
+ else:
132
+ # Unclosed fence: drop the opening line, keep the rest of the text
133
+ inner = text[open_end:]
134
+ result_parts.append(inner)
135
+ cursor = len(text)
136
+ break # nothing left to process
137
+
138
+ # Append any trailing text after the last fence
139
+ result_parts.append(text[cursor:])
140
+
141
+ return "".join(result_parts)
142
+
143
+
144
+ def _find_closing_fence(text: str, search_from: int, fence_type: str, min_len: int):
145
+ """Return the first closing fence match at or after *search_from*."""
146
+ for m in _CLOSE_FENCE_RE.finditer(text, search_from):
147
+ fc = m.group("fence")
148
+ if fc[0] == fence_type and len(fc) >= min_len:
149
+ return m
150
+ return None
151
+
152
+
153
+ def _normalize_blank_lines(text: str) -> str:
154
+ """Collapse 3+ consecutive blank lines down to 2."""
155
+ return re.sub(r"\n{3,}", "\n\n", text)
@@ -0,0 +1,354 @@
1
+ """
2
+ json_utils.py — extract and repair valid JSON from messy LLM output.
3
+
4
+ LLMs routinely return JSON wrapped in prose, fences, or with small syntax
5
+ errors. This module tries a pipeline of increasingly aggressive strategies
6
+ to get back a valid, parse-able JSON string.
7
+
8
+ Strategy pipeline (stops at first success):
9
+ 1. Parse as-is (already valid JSON)
10
+ 2. Strip fences then parse
11
+ 3. Strip leading/trailing prose, leaving only the JSON substring
12
+ (handles "Sure! Here is your JSON: {...} Hope that helps!")
13
+ 4. Remove trailing commas before ] or }
14
+ 5. Attempt to fix unquoted keys (moderate repair)
15
+ 6. Attempt to close unclosed brackets/braces
16
+ 7. Combination of fixes 4+5+6
17
+
18
+ If every strategy fails the original text is returned unchanged so the
19
+ caller can decide what to do.
20
+ """
21
+
22
+ import json
23
+ import re
24
+ from .fences import strip_fences
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Public API
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def enforce_json(text: str) -> str:
31
+ """Attempt to extract and return valid JSON from *text*.
32
+
33
+ Applies a pipeline of cleaning strategies. The first strategy that
34
+ produces parse-able JSON wins; its output (the cleaned JSON string) is
35
+ returned. If nothing works the original text is returned unchanged.
36
+
37
+ The returned string, when a strategy succeeds, is re-serialized with
38
+ ``json.dumps`` so it is always consistently formatted.
39
+
40
+ Parameters
41
+ ----------
42
+ text:
43
+ Raw LLM output that should contain JSON somewhere.
44
+
45
+ Returns
46
+ -------
47
+ str
48
+ A valid JSON string, or the original *text* if extraction failed.
49
+ """
50
+ if not isinstance(text, str):
51
+ return text
52
+
53
+ original = text
54
+
55
+ try:
56
+ return _run_pipeline(text.strip())
57
+ except Exception:
58
+ return original
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Pipeline
63
+ # ---------------------------------------------------------------------------
64
+
65
+ def _run_pipeline(text: str) -> str:
66
+ strategies = [
67
+ _try_parse_direct,
68
+ _try_strip_fences,
69
+ _try_extract_json_substring,
70
+ _try_fix_trailing_commas,
71
+ _try_fix_python_literals,
72
+ _try_fix_unquoted_keys,
73
+ _try_close_open_brackets,
74
+ _try_combined_fixes,
75
+ ]
76
+ for strategy in strategies:
77
+ result = strategy(text)
78
+ if result is not None:
79
+ return result
80
+ # Nothing worked — return original
81
+ return text
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Strategies (each returns a clean JSON *string* or None)
86
+ # ---------------------------------------------------------------------------
87
+
88
+ def _try_parse_direct(text: str):
89
+ """Strategy 1: already valid JSON."""
90
+ return _parse_and_serialize(text)
91
+
92
+
93
+ def _try_strip_fences(text: str):
94
+ """Strategy 2: strip code fences then parse."""
95
+ stripped = strip_fences(text)
96
+ if stripped == text:
97
+ return None # no change, skip
98
+ return _parse_and_serialize(stripped)
99
+
100
+
101
+ def _try_extract_json_substring(text: str):
102
+ """Strategy 3: find the first JSON object or array in the text.
103
+
104
+ Scans for the first '{' or '[' and tries progressively larger substrings
105
+ until one parses. Also tries from the *last* '}' or ']' backwards.
106
+ This handles patterns like:
107
+ 'Sure, here is the data: {"key": "value"} Let me know if...'
108
+ """
109
+ # Try object extraction
110
+ result = _extract_by_brackets(text, "{", "}")
111
+ if result is not None:
112
+ return result
113
+ # Try array extraction
114
+ return _extract_by_brackets(text, "[", "]")
115
+
116
+
117
+ def _extract_by_brackets(text: str, open_char: str, close_char: str):
118
+ """Find the outermost balanced bracket pair and try to parse it."""
119
+ start = text.find(open_char)
120
+ if start == -1:
121
+ return None
122
+
123
+ end = text.rfind(close_char)
124
+ if end == -1 or end <= start:
125
+ return None
126
+
127
+ # Try from outermost to innermost close bracket
128
+ candidate = text[start:end + 1]
129
+ result = _parse_and_serialize(candidate)
130
+ if result is not None:
131
+ return result
132
+
133
+ # Walk inward if the outer attempt fails (handles trailing junk)
134
+ for i in range(end - 1, start, -1):
135
+ if text[i] == close_char:
136
+ result = _parse_and_serialize(text[start:i + 1])
137
+ if result is not None:
138
+ return result
139
+ return None
140
+
141
+
142
+ def _try_fix_trailing_commas(text: str):
143
+ """Strategy 4: remove trailing commas before closing brackets."""
144
+ cleaned = _remove_trailing_commas(text)
145
+ if cleaned == text:
146
+ return None
147
+ return _parse_and_serialize(cleaned)
148
+
149
+
150
+ def _try_fix_python_literals(text: str):
151
+ """Strategy 5: replace Python literals that LLMs emit instead of JSON.
152
+
153
+ LLMs frequently output Python-style values inside otherwise valid JSON:
154
+ True / False / None -> true / false / null
155
+ Single-quoted strings: {'key': 'val'} -> {"key": "val"}
156
+
157
+ Applied word-boundary-aware so legitimate content is not corrupted.
158
+ """
159
+ cleaned = _replace_python_literals(text)
160
+ if cleaned == text:
161
+ return None
162
+ return _parse_and_serialize(cleaned)
163
+
164
+
165
+ def _try_fix_unquoted_keys(text: str):
166
+ """Strategy 6: quote bare word keys like {key: value} -> {"key": value}."""
167
+ cleaned = _quote_unquoted_keys(text)
168
+ if cleaned == text:
169
+ return None
170
+ return _parse_and_serialize(cleaned)
171
+
172
+
173
+ def _try_close_open_brackets(text: str):
174
+ """Strategy 7: append missing closing brackets/braces."""
175
+ cleaned = _close_open_structures(text)
176
+ if cleaned == text:
177
+ return None
178
+ return _parse_and_serialize(cleaned)
179
+
180
+
181
+ def _try_combined_fixes(text: str):
182
+ """Strategy 8: apply all fixers in sequence."""
183
+ cleaned = _replace_python_literals(text)
184
+ cleaned = _remove_trailing_commas(cleaned)
185
+ cleaned = _quote_unquoted_keys(cleaned)
186
+ cleaned = _close_open_structures(cleaned)
187
+ if cleaned == text:
188
+ return None
189
+ # Also try substring extraction on the combined fix
190
+ result = _parse_and_serialize(cleaned)
191
+ if result is not None:
192
+ return result
193
+ return _try_extract_json_substring(cleaned)
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Fixers
198
+ # ---------------------------------------------------------------------------
199
+
200
+ # Trailing comma before } or ] (also handles whitespace/newlines between)
201
+ _TRAILING_COMMA_RE = re.compile(r",\s*([}\]])")
202
+
203
+ def _remove_trailing_commas(text: str) -> str:
204
+ return _TRAILING_COMMA_RE.sub(r"\1", text)
205
+
206
+
207
+ # Bare (unquoted) object keys: { key: ... } → { "key": ... }
208
+ # Only matches word-characters; won't disturb already-quoted keys.
209
+ _UNQUOTED_KEY_RE = re.compile(r'(?<!["\w])(\b[a-zA-Z_][a-zA-Z0-9_]*\b)\s*(?=:)')
210
+
211
+ def _quote_unquoted_keys(text: str) -> str:
212
+ # Only operate inside what looks like a JSON object
213
+ start = text.find("{")
214
+ if start == -1:
215
+ return text
216
+ prefix = text[:start]
217
+ body = text[start:]
218
+ fixed = _UNQUOTED_KEY_RE.sub(r'"\1"', body)
219
+ return prefix + fixed
220
+
221
+
222
+ def _close_open_structures(text: str) -> str:
223
+ """Append any missing closing } or ] characters."""
224
+ stack = []
225
+ in_string = False
226
+ escape_next = False
227
+ pairs = {"{": "}", "[": "]"}
228
+ closers = set(pairs.values())
229
+
230
+ for ch in text:
231
+ if escape_next:
232
+ escape_next = False
233
+ continue
234
+ if ch == "\\" and in_string:
235
+ escape_next = True
236
+ continue
237
+ if ch == '"':
238
+ in_string = not in_string
239
+ continue
240
+ if in_string:
241
+ continue
242
+ if ch in pairs:
243
+ stack.append(pairs[ch])
244
+ elif ch in closers:
245
+ if stack and stack[-1] == ch:
246
+ stack.pop()
247
+
248
+ # Append missing closers in reverse order
249
+ return text + "".join(reversed(stack))
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # Utility
254
+ # ---------------------------------------------------------------------------
255
+
256
+ def _parse_and_serialize(text: str):
257
+ """Try to parse *text* as JSON; return re-serialized string or None."""
258
+ try:
259
+ parsed = json.loads(text.strip())
260
+ return json.dumps(parsed, ensure_ascii=False, indent=2)
261
+ except (json.JSONDecodeError, ValueError):
262
+ return None
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # Python-literal fixer (added separately for clarity)
267
+ # ---------------------------------------------------------------------------
268
+
269
+ # Word-boundary replacements for Python boolean/None literals
270
+ _PYTHON_LITERAL_RE = re.compile(r'\bTrue\b|\bFalse\b|\bNone\b')
271
+ _PYTHON_LITERAL_MAP = {"True": "true", "False": "false", "None": "null"}
272
+
273
+ def _replace_python_literals(text: str) -> str:
274
+ """Replace Python True/False/None with JSON true/false/null.
275
+
276
+ Also converts single-quoted strings to double-quoted where safe.
277
+ Only operates outside of already-valid JSON strings to avoid
278
+ corrupting content that legitimately contains these words.
279
+ """
280
+ # Step 1: True / False / None (simple word-boundary swap)
281
+ result = _PYTHON_LITERAL_RE.sub(lambda m: _PYTHON_LITERAL_MAP[m.group()], text)
282
+
283
+ # Step 2: single-quoted strings -> double-quoted
284
+ # Strategy: only replace 'value' patterns that look like JSON string values
285
+ # or keys (preceded by { , or : and optional whitespace).
286
+ # This is intentionally conservative to avoid mangling prose.
287
+ result = _single_to_double_quotes(result)
288
+ return result
289
+
290
+
291
+ def _single_to_double_quotes(text: str) -> str:
292
+ """Convert single-quoted JSON-ish strings to double-quoted JSON."""
293
+ result = []
294
+ i = 0
295
+ in_single = False
296
+ in_double = False
297
+ sq = chr(39) # '
298
+ dq = chr(34) # "
299
+ bs = chr(92) # \
300
+ while i < len(text):
301
+ ch = text[i]
302
+ if ch == bs and (in_single or in_double):
303
+ result.append(ch)
304
+ i += 1
305
+ if i < len(text):
306
+ result.append(text[i])
307
+ i += 1
308
+ continue
309
+ if ch == sq and not in_double:
310
+ in_single = not in_single
311
+ result.append(dq)
312
+ elif ch == dq and not in_single:
313
+ in_double = not in_double
314
+ result.append(ch)
315
+ elif ch == dq and in_single:
316
+ result.append(bs + dq)
317
+ else:
318
+ result.append(ch)
319
+ i += 1
320
+ return "".join(result)
321
+
322
+ def _parse_and_serialize(text: str):
323
+ """Try to parse *text* as JSON; return re-serialized string or None."""
324
+ try:
325
+ parsed = json.loads(text.strip())
326
+ return json.dumps(parsed, ensure_ascii=False, indent=2)
327
+ except (json.JSONDecodeError, ValueError):
328
+ return None
329
+
330
+
331
+ # ---------------------------------------------------------------------------
332
+ # Python-literal fixer (added separately for clarity)
333
+ # ---------------------------------------------------------------------------
334
+
335
+ # Word-boundary replacements for Python boolean/None literals
336
+ _PYTHON_LITERAL_RE = re.compile(r'\bTrue\b|\bFalse\b|\bNone\b')
337
+ _PYTHON_LITERAL_MAP = {"True": "true", "False": "false", "None": "null"}
338
+
339
+ def _replace_python_literals(text: str) -> str:
340
+ """Replace Python True/False/None with JSON true/false/null.
341
+
342
+ Also converts single-quoted strings to double-quoted where safe.
343
+ Only operates outside of already-valid JSON strings to avoid
344
+ corrupting content that legitimately contains these words.
345
+ """
346
+ # Step 1: True / False / None (simple word-boundary swap)
347
+ result = _PYTHON_LITERAL_RE.sub(lambda m: _PYTHON_LITERAL_MAP[m.group()], text)
348
+
349
+ # Step 2: single-quoted strings -> double-quoted
350
+ # Strategy: only replace 'value' patterns that look like JSON string values
351
+ # or keys (preceded by { , or : and optional whitespace).
352
+ # This is intentionally conservative to avoid mangling prose.
353
+ result = _single_to_double_quotes(result)
354
+ return result