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.
- llmclean-0.1.0/PKG-INFO +100 -0
- llmclean-0.1.0/README.md +78 -0
- llmclean-0.1.0/llmclean/__init__.py +18 -0
- llmclean-0.1.0/llmclean/fences.py +155 -0
- llmclean-0.1.0/llmclean/json_utils.py +354 -0
- llmclean-0.1.0/llmclean/repetition.py +257 -0
- llmclean-0.1.0/llmclean.egg-info/PKG-INFO +100 -0
- llmclean-0.1.0/llmclean.egg-info/SOURCES.txt +14 -0
- llmclean-0.1.0/llmclean.egg-info/dependency_links.txt +1 -0
- llmclean-0.1.0/llmclean.egg-info/requires.txt +3 -0
- llmclean-0.1.0/llmclean.egg-info/top_level.txt +1 -0
- llmclean-0.1.0/pyproject.toml +41 -0
- llmclean-0.1.0/setup.cfg +4 -0
- llmclean-0.1.0/tests/test_fences.py +132 -0
- llmclean-0.1.0/tests/test_json.py +205 -0
- llmclean-0.1.0/tests/test_repetition.py +141 -0
llmclean-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
llmclean-0.1.0/README.md
ADDED
|
@@ -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
|