llmparse-py 0.1.1__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.
- llmparse_py-0.1.1/LICENSE +21 -0
- llmparse_py-0.1.1/PKG-INFO +100 -0
- llmparse_py-0.1.1/README.md +79 -0
- llmparse_py-0.1.1/pyproject.toml +33 -0
- llmparse_py-0.1.1/setup.cfg +4 -0
- llmparse_py-0.1.1/src/llmparse/__init__.py +12 -0
- llmparse_py-0.1.1/src/llmparse/core.py +93 -0
- llmparse_py-0.1.1/src/llmparse/repair.py +192 -0
- llmparse_py-0.1.1/src/llmparse/scanner.py +92 -0
- llmparse_py-0.1.1/src/llmparse_py.egg-info/PKG-INFO +100 -0
- llmparse_py-0.1.1/src/llmparse_py.egg-info/SOURCES.txt +13 -0
- llmparse_py-0.1.1/src/llmparse_py.egg-info/dependency_links.txt +1 -0
- llmparse_py-0.1.1/src/llmparse_py.egg-info/top_level.txt +1 -0
- llmparse_py-0.1.1/tests/test_parse.py +90 -0
- llmparse_py-0.1.1/tests/test_rules.py +43 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gptfei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llmparse-py
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Parse the JSON your LLM meant to send — zero-dependency extraction and repair for raw LLM output.
|
|
5
|
+
Author: gptfei
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/gptfei/llmparse
|
|
8
|
+
Project-URL: Repository, https://github.com/gptfei/llmparse
|
|
9
|
+
Project-URL: Issues, https://github.com/gptfei/llmparse/issues
|
|
10
|
+
Keywords: llm,json,parser,repair,openai,chatgpt,claude,structured-output
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Topic :: Text Processing
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# llmparse
|
|
23
|
+
|
|
24
|
+
**Parse the JSON your LLM meant to send.**
|
|
25
|
+
|
|
26
|
+
LLMs promise JSON and deliver markdown fences, apologies, single
|
|
27
|
+
quotes, trailing commas, Python literals — and, when the token budget
|
|
28
|
+
runs out, JSON that stops mid-string. `llmparse` is a zero-dependency
|
|
29
|
+
Python library that extracts the JSON from raw model output and repairs
|
|
30
|
+
the damage, so `json.loads` stops being the most fragile line in your
|
|
31
|
+
pipeline.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from llmparse import parse
|
|
35
|
+
|
|
36
|
+
parse('Sure! Here you go:\n```json\n{name: "ada", tags: ["x", "y",], ok: True}\n```')
|
|
37
|
+
# {'name': 'ada', 'tags': ['x', 'y'], 'ok': True}
|
|
38
|
+
|
|
39
|
+
parse('{"name": "ada", "tags": ["x", "y"') # truncated stream
|
|
40
|
+
# {'name': 'ada', 'tags': ['x', 'y']}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install llmparse-py # once published to PyPI
|
|
47
|
+
pip install git+https://github.com/gptfei/llmparse
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Python 3.9+. No third-party dependencies, ever.
|
|
51
|
+
|
|
52
|
+
## What it fixes
|
|
53
|
+
|
|
54
|
+
| Damage in LLM output | Example |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| Markdown fences | ` ```json ... ``` ` |
|
|
57
|
+
| Prose around the JSON | `Here is the result: {...} hope it helps` |
|
|
58
|
+
| Single-quoted strings | `{'a': 'b'}` |
|
|
59
|
+
| Unquoted keys | `{name: "ada"}` |
|
|
60
|
+
| Python / JS literals | `True`, `None`, `undefined`, `NaN` |
|
|
61
|
+
| Comments | `{"a": 1} // note` |
|
|
62
|
+
| Trailing commas | `{"a": [1, 2,]}` |
|
|
63
|
+
| Truncation | `{"msg": "hello wor` → closed and parsed |
|
|
64
|
+
|
|
65
|
+
Brackets and comment markers *inside* string values are never touched.
|
|
66
|
+
|
|
67
|
+
## API
|
|
68
|
+
|
|
69
|
+
- `parse(text, default=...)` — return the recovered Python object.
|
|
70
|
+
Raises `LLMParseError` when nothing is recoverable, unless `default`
|
|
71
|
+
is given. `loads` is an alias.
|
|
72
|
+
- `extract(text)` — return the first recoverable JSON substring, or `None`.
|
|
73
|
+
- `repair(text)` — return repaired JSON *text* (for when you need the
|
|
74
|
+
string, not the object).
|
|
75
|
+
|
|
76
|
+
Valid JSON is returned untouched: every repair rule is a no-op on
|
|
77
|
+
input that already parses.
|
|
78
|
+
|
|
79
|
+
## Contributing
|
|
80
|
+
|
|
81
|
+
The repair pipeline is a list of small, independent rules — adding one
|
|
82
|
+
is the easiest meaningful contribution in open source right now:
|
|
83
|
+
|
|
84
|
+
1. Find a way an LLM broke JSON that llmparse does not fix yet.
|
|
85
|
+
2. Write the rule in `src/llmparse/repair.py`, register it in `RULES`.
|
|
86
|
+
3. Add a test with the broken input.
|
|
87
|
+
|
|
88
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and issues labelled
|
|
89
|
+
`good first issue`.
|
|
90
|
+
|
|
91
|
+
## 中文简介
|
|
92
|
+
|
|
93
|
+
大模型说好返回 JSON,实际经常带 markdown 围栏、解释文字、单引号、
|
|
94
|
+
尾随逗号、Python 的 `True/None`,甚至输出到一半被截断。`llmparse`
|
|
95
|
+
是一个零依赖的 Python 库:从原始输出里提取 JSON、逐项修复这些损坏、
|
|
96
|
+
再解析成 Python 对象。合法 JSON 不会被改动。
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# llmparse
|
|
2
|
+
|
|
3
|
+
**Parse the JSON your LLM meant to send.**
|
|
4
|
+
|
|
5
|
+
LLMs promise JSON and deliver markdown fences, apologies, single
|
|
6
|
+
quotes, trailing commas, Python literals — and, when the token budget
|
|
7
|
+
runs out, JSON that stops mid-string. `llmparse` is a zero-dependency
|
|
8
|
+
Python library that extracts the JSON from raw model output and repairs
|
|
9
|
+
the damage, so `json.loads` stops being the most fragile line in your
|
|
10
|
+
pipeline.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from llmparse import parse
|
|
14
|
+
|
|
15
|
+
parse('Sure! Here you go:\n```json\n{name: "ada", tags: ["x", "y",], ok: True}\n```')
|
|
16
|
+
# {'name': 'ada', 'tags': ['x', 'y'], 'ok': True}
|
|
17
|
+
|
|
18
|
+
parse('{"name": "ada", "tags": ["x", "y"') # truncated stream
|
|
19
|
+
# {'name': 'ada', 'tags': ['x', 'y']}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install llmparse-py # once published to PyPI
|
|
26
|
+
pip install git+https://github.com/gptfei/llmparse
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Python 3.9+. No third-party dependencies, ever.
|
|
30
|
+
|
|
31
|
+
## What it fixes
|
|
32
|
+
|
|
33
|
+
| Damage in LLM output | Example |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| Markdown fences | ` ```json ... ``` ` |
|
|
36
|
+
| Prose around the JSON | `Here is the result: {...} hope it helps` |
|
|
37
|
+
| Single-quoted strings | `{'a': 'b'}` |
|
|
38
|
+
| Unquoted keys | `{name: "ada"}` |
|
|
39
|
+
| Python / JS literals | `True`, `None`, `undefined`, `NaN` |
|
|
40
|
+
| Comments | `{"a": 1} // note` |
|
|
41
|
+
| Trailing commas | `{"a": [1, 2,]}` |
|
|
42
|
+
| Truncation | `{"msg": "hello wor` → closed and parsed |
|
|
43
|
+
|
|
44
|
+
Brackets and comment markers *inside* string values are never touched.
|
|
45
|
+
|
|
46
|
+
## API
|
|
47
|
+
|
|
48
|
+
- `parse(text, default=...)` — return the recovered Python object.
|
|
49
|
+
Raises `LLMParseError` when nothing is recoverable, unless `default`
|
|
50
|
+
is given. `loads` is an alias.
|
|
51
|
+
- `extract(text)` — return the first recoverable JSON substring, or `None`.
|
|
52
|
+
- `repair(text)` — return repaired JSON *text* (for when you need the
|
|
53
|
+
string, not the object).
|
|
54
|
+
|
|
55
|
+
Valid JSON is returned untouched: every repair rule is a no-op on
|
|
56
|
+
input that already parses.
|
|
57
|
+
|
|
58
|
+
## Contributing
|
|
59
|
+
|
|
60
|
+
The repair pipeline is a list of small, independent rules — adding one
|
|
61
|
+
is the easiest meaningful contribution in open source right now:
|
|
62
|
+
|
|
63
|
+
1. Find a way an LLM broke JSON that llmparse does not fix yet.
|
|
64
|
+
2. Write the rule in `src/llmparse/repair.py`, register it in `RULES`.
|
|
65
|
+
3. Add a test with the broken input.
|
|
66
|
+
|
|
67
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and issues labelled
|
|
68
|
+
`good first issue`.
|
|
69
|
+
|
|
70
|
+
## 中文简介
|
|
71
|
+
|
|
72
|
+
大模型说好返回 JSON,实际经常带 markdown 围栏、解释文字、单引号、
|
|
73
|
+
尾随逗号、Python 的 `True/None`,甚至输出到一半被截断。`llmparse`
|
|
74
|
+
是一个零依赖的 Python 库:从原始输出里提取 JSON、逐项修复这些损坏、
|
|
75
|
+
再解析成 Python 对象。合法 JSON 不会被改动。
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "llmparse-py"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Parse the JSON your LLM meant to send — zero-dependency extraction and repair for raw LLM output."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "gptfei" }]
|
|
13
|
+
keywords = ["llm", "json", "parser", "repair", "openai", "chatgpt", "claude", "structured-output"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
"Topic :: Text Processing",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/gptfei/llmparse"
|
|
26
|
+
Repository = "https://github.com/gptfei/llmparse"
|
|
27
|
+
Issues = "https://github.com/gptfei/llmparse/issues"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""llmparse — parse the JSON your LLM meant to send.
|
|
2
|
+
|
|
3
|
+
Zero-dependency extraction and repair for JSON embedded in raw LLM
|
|
4
|
+
output: markdown fences, prose, single quotes, trailing commas,
|
|
5
|
+
Python literals, comments and truncated streams.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .core import LLMParseError, extract, loads, parse
|
|
9
|
+
from .repair import repair
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.1"
|
|
12
|
+
__all__ = ["LLMParseError", "extract", "loads", "parse", "repair", "__version__"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Extraction and parsing: find the JSON inside LLM output and load it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .repair import RULES, repair
|
|
9
|
+
from .scanner import find_balanced_end
|
|
10
|
+
|
|
11
|
+
__all__ = ["LLMParseError", "extract", "parse", "loads", "repair"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LLMParseError(ValueError):
|
|
15
|
+
"""Raised when no JSON value can be recovered from the text."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _try_loads(text: str) -> tuple[bool, Any]:
|
|
19
|
+
try:
|
|
20
|
+
return True, json.loads(text)
|
|
21
|
+
except (ValueError, TypeError):
|
|
22
|
+
return False, None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_with_repairs(text: str) -> tuple[bool, Any]:
|
|
26
|
+
"""Try plain parsing, then re-try after each repair stage."""
|
|
27
|
+
ok, value = _try_loads(text.strip())
|
|
28
|
+
if ok:
|
|
29
|
+
return True, value
|
|
30
|
+
current = text
|
|
31
|
+
for rule in RULES:
|
|
32
|
+
current = rule(current)
|
|
33
|
+
ok, value = _try_loads(current.strip())
|
|
34
|
+
if ok:
|
|
35
|
+
return True, value
|
|
36
|
+
return False, None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract(text: str) -> str | None:
|
|
40
|
+
"""Return the first recoverable JSON substring of ``text``, or None.
|
|
41
|
+
|
|
42
|
+
Candidates start at every ``{`` or ``[`` outside the previous
|
|
43
|
+
candidate; each candidate is cut at its balanced end (or at end of
|
|
44
|
+
text when truncated) and tested with the full repair pipeline.
|
|
45
|
+
"""
|
|
46
|
+
if not text:
|
|
47
|
+
return None
|
|
48
|
+
ok, _ = _load_with_repairs(text)
|
|
49
|
+
if ok:
|
|
50
|
+
return repair(text).strip() if "```" in text else text.strip()
|
|
51
|
+
for start, ch in enumerate(text):
|
|
52
|
+
if ch not in "{[":
|
|
53
|
+
continue
|
|
54
|
+
end = find_balanced_end(text, start)
|
|
55
|
+
candidate = text[start:end] if end is not None else text[start:]
|
|
56
|
+
ok, _ = _load_with_repairs(candidate)
|
|
57
|
+
if ok:
|
|
58
|
+
return candidate
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_SENTINEL = object()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse(text: str, *, default: Any = _SENTINEL) -> Any:
|
|
66
|
+
"""Parse JSON out of raw LLM output.
|
|
67
|
+
|
|
68
|
+
Handles markdown fences, surrounding prose, single quotes, bare
|
|
69
|
+
keys, Python literals, comments, trailing commas and truncation.
|
|
70
|
+
Returns ``default`` when given and nothing can be recovered;
|
|
71
|
+
otherwise raises :class:`LLMParseError`.
|
|
72
|
+
"""
|
|
73
|
+
if isinstance(text, (dict, list)):
|
|
74
|
+
return text
|
|
75
|
+
if not isinstance(text, str) or not text.strip():
|
|
76
|
+
if default is not _SENTINEL:
|
|
77
|
+
return default
|
|
78
|
+
raise LLMParseError("no JSON found: empty input")
|
|
79
|
+
ok, value = _load_with_repairs(text)
|
|
80
|
+
if ok:
|
|
81
|
+
return value
|
|
82
|
+
candidate = extract(text)
|
|
83
|
+
if candidate is not None:
|
|
84
|
+
ok, value = _load_with_repairs(candidate)
|
|
85
|
+
if ok:
|
|
86
|
+
return value
|
|
87
|
+
if default is not _SENTINEL:
|
|
88
|
+
return default
|
|
89
|
+
raise LLMParseError(f"could not recover JSON from: {text[:120]!r}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Familiar alias for users coming from the json module.
|
|
93
|
+
loads = parse
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Repair rules for malformed JSON commonly produced by LLMs.
|
|
2
|
+
|
|
3
|
+
Each rule is a small ``str -> str`` function that fixes one class of
|
|
4
|
+
damage and is safe to apply to already-valid JSON (it must not change
|
|
5
|
+
valid input). Rules run in the order listed in ``RULES``; the parser
|
|
6
|
+
tries ``json.loads`` after every stage and stops as soon as it works.
|
|
7
|
+
|
|
8
|
+
Adding a rule is the easiest meaningful contribution to llmparse:
|
|
9
|
+
write the function, register it in ``RULES``, and add a test case with
|
|
10
|
+
a real-world-style broken input. See CONTRIBUTING.md.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from .scanner import in_string_at_end, iter_chars, unclosed_brackets
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def strip_code_fences(text: str) -> str:
|
|
21
|
+
"""Remove surrounding markdown ``` fences, if present."""
|
|
22
|
+
stripped = text.strip()
|
|
23
|
+
if "```" not in stripped:
|
|
24
|
+
return text
|
|
25
|
+
match = re.search(r"```(?:json|JSON)?\s*\n?(.*?)(?:```|$)", stripped, re.DOTALL)
|
|
26
|
+
return match.group(1).strip() if match else text
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def remove_comments(text: str) -> str:
|
|
30
|
+
"""Remove ``//`` line and ``/* */`` block comments outside strings."""
|
|
31
|
+
out: list[str] = []
|
|
32
|
+
i = 0
|
|
33
|
+
in_string = False
|
|
34
|
+
quote = ""
|
|
35
|
+
escaped = False
|
|
36
|
+
while i < len(text):
|
|
37
|
+
ch = text[i]
|
|
38
|
+
if in_string:
|
|
39
|
+
out.append(ch)
|
|
40
|
+
if escaped:
|
|
41
|
+
escaped = False
|
|
42
|
+
elif ch == "\\":
|
|
43
|
+
escaped = True
|
|
44
|
+
elif ch == quote:
|
|
45
|
+
in_string = False
|
|
46
|
+
i += 1
|
|
47
|
+
continue
|
|
48
|
+
if ch in ("'", '"'):
|
|
49
|
+
in_string, quote = True, ch
|
|
50
|
+
out.append(ch)
|
|
51
|
+
i += 1
|
|
52
|
+
continue
|
|
53
|
+
if ch == "/" and i + 1 < len(text) and text[i + 1] == "/":
|
|
54
|
+
while i < len(text) and text[i] != "\n":
|
|
55
|
+
i += 1
|
|
56
|
+
continue
|
|
57
|
+
if ch == "/" and i + 1 < len(text) and text[i + 1] == "*":
|
|
58
|
+
i += 2
|
|
59
|
+
while i + 1 < len(text) and not (text[i] == "*" and text[i + 1] == "/"):
|
|
60
|
+
i += 1
|
|
61
|
+
i += 2
|
|
62
|
+
continue
|
|
63
|
+
out.append(ch)
|
|
64
|
+
i += 1
|
|
65
|
+
return "".join(out)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def single_to_double_quotes(text: str) -> str:
|
|
69
|
+
"""Convert single-quoted strings to double-quoted strings."""
|
|
70
|
+
out: list[str] = []
|
|
71
|
+
in_string = False
|
|
72
|
+
quote = ""
|
|
73
|
+
escaped = False
|
|
74
|
+
for ch in text:
|
|
75
|
+
if in_string:
|
|
76
|
+
if escaped:
|
|
77
|
+
out.append(ch)
|
|
78
|
+
escaped = False
|
|
79
|
+
elif ch == "\\":
|
|
80
|
+
out.append(ch)
|
|
81
|
+
escaped = True
|
|
82
|
+
elif ch == quote:
|
|
83
|
+
out.append('"')
|
|
84
|
+
in_string = False
|
|
85
|
+
elif ch == '"' and quote == "'":
|
|
86
|
+
out.append('\\"')
|
|
87
|
+
else:
|
|
88
|
+
out.append(ch)
|
|
89
|
+
else:
|
|
90
|
+
if ch in ("'", '"'):
|
|
91
|
+
in_string, quote = True, ch
|
|
92
|
+
out.append('"')
|
|
93
|
+
else:
|
|
94
|
+
out.append(ch)
|
|
95
|
+
return "".join(out)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_LITERAL_MAP = {
|
|
99
|
+
"True": "true",
|
|
100
|
+
"False": "false",
|
|
101
|
+
"None": "null",
|
|
102
|
+
"undefined": "null",
|
|
103
|
+
"NaN": "null",
|
|
104
|
+
"Infinity": "null",
|
|
105
|
+
"-Infinity": "null",
|
|
106
|
+
}
|
|
107
|
+
_LITERAL_RE = re.compile(r"\b(True|False|None|undefined|NaN|Infinity|-Infinity)\b")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def fix_literals(text: str) -> str:
|
|
111
|
+
"""Replace Python/JavaScript literals with JSON ones, outside strings."""
|
|
112
|
+
out: list[str] = []
|
|
113
|
+
buffer: list[str] = []
|
|
114
|
+
for _, ch, in_string, _ in iter_chars(text):
|
|
115
|
+
if in_string:
|
|
116
|
+
if buffer:
|
|
117
|
+
out.append(_LITERAL_RE.sub(lambda m: _LITERAL_MAP[m.group(1)], "".join(buffer)))
|
|
118
|
+
buffer = []
|
|
119
|
+
out.append(ch)
|
|
120
|
+
else:
|
|
121
|
+
buffer.append(ch)
|
|
122
|
+
if buffer:
|
|
123
|
+
out.append(_LITERAL_RE.sub(lambda m: _LITERAL_MAP[m.group(1)], "".join(buffer)))
|
|
124
|
+
return "".join(out)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_UNQUOTED_KEY_RE = re.compile(r"([{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)(\s*:)")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def quote_unquoted_keys(text: str) -> str:
|
|
131
|
+
"""Quote bare identifier keys: ``{name: 1}`` -> ``{"name": 1}``."""
|
|
132
|
+
out: list[str] = []
|
|
133
|
+
buffer: list[str] = []
|
|
134
|
+
for _, ch, in_string, _ in iter_chars(text):
|
|
135
|
+
if in_string:
|
|
136
|
+
if buffer:
|
|
137
|
+
out.append(_UNQUOTED_KEY_RE.sub(r'\1"\2"\3', "".join(buffer)))
|
|
138
|
+
buffer = []
|
|
139
|
+
out.append(ch)
|
|
140
|
+
else:
|
|
141
|
+
buffer.append(ch)
|
|
142
|
+
if buffer:
|
|
143
|
+
out.append(_UNQUOTED_KEY_RE.sub(r'\1"\2"\3', "".join(buffer)))
|
|
144
|
+
return "".join(out)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def remove_trailing_commas(text: str) -> str:
|
|
148
|
+
"""Drop commas that immediately precede ``}`` or ``]`` outside strings."""
|
|
149
|
+
chars = [(ch, in_string) for _, ch, in_string, _ in iter_chars(text)]
|
|
150
|
+
out: list[str] = []
|
|
151
|
+
for idx, (ch, in_string) in enumerate(chars):
|
|
152
|
+
if ch == "," and not in_string:
|
|
153
|
+
lookahead = idx + 1
|
|
154
|
+
while lookahead < len(chars) and chars[lookahead][0] in " \t\r\n":
|
|
155
|
+
lookahead += 1
|
|
156
|
+
if lookahead < len(chars) and chars[lookahead][0] in "}]":
|
|
157
|
+
continue
|
|
158
|
+
out.append(ch)
|
|
159
|
+
return "".join(out)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def close_truncated(text: str) -> str:
|
|
163
|
+
"""Close an unterminated string and any unclosed brackets, in order."""
|
|
164
|
+
result = text.rstrip()
|
|
165
|
+
if not result:
|
|
166
|
+
return result
|
|
167
|
+
# A dangling escape at the very end would swallow the closing quote.
|
|
168
|
+
if result.endswith("\\") and not result.endswith("\\\\"):
|
|
169
|
+
result = result[:-1]
|
|
170
|
+
closers = ""
|
|
171
|
+
if in_string_at_end(result):
|
|
172
|
+
closers += '"'
|
|
173
|
+
closers += "".join(unclosed_brackets(result))
|
|
174
|
+
return result + closers
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
RULES = [
|
|
178
|
+
strip_code_fences,
|
|
179
|
+
remove_comments,
|
|
180
|
+
single_to_double_quotes,
|
|
181
|
+
fix_literals,
|
|
182
|
+
quote_unquoted_keys,
|
|
183
|
+
remove_trailing_commas,
|
|
184
|
+
close_truncated,
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def repair(text: str) -> str:
|
|
189
|
+
"""Apply every repair rule in order and return the repaired text."""
|
|
190
|
+
for rule in RULES:
|
|
191
|
+
text = rule(text)
|
|
192
|
+
return text
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Character scanners shared by extraction and repair.
|
|
2
|
+
|
|
3
|
+
Everything here walks the text once while tracking whether we are
|
|
4
|
+
inside a string, so brackets or comment markers inside string values
|
|
5
|
+
are never mistaken for structure. Strings may be double-quoted,
|
|
6
|
+
single-quoted, or (in truncated output) unterminated.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def iter_chars(text: str):
|
|
13
|
+
"""Yield ``(index, char, in_string, quote_char)`` for each position.
|
|
14
|
+
|
|
15
|
+
``in_string`` describes the state *at* the character: opening and
|
|
16
|
+
closing quote characters themselves are yielded with
|
|
17
|
+
``in_string == False`` for the opener and ``True`` for the closer.
|
|
18
|
+
Backslash escapes inside strings are honoured.
|
|
19
|
+
"""
|
|
20
|
+
in_string = False
|
|
21
|
+
quote = ""
|
|
22
|
+
escaped = False
|
|
23
|
+
for i, ch in enumerate(text):
|
|
24
|
+
if in_string:
|
|
25
|
+
yield i, ch, True, quote
|
|
26
|
+
if escaped:
|
|
27
|
+
escaped = False
|
|
28
|
+
elif ch == "\\":
|
|
29
|
+
escaped = True
|
|
30
|
+
elif ch == quote:
|
|
31
|
+
in_string = False
|
|
32
|
+
quote = ""
|
|
33
|
+
else:
|
|
34
|
+
yield i, ch, False, ""
|
|
35
|
+
if ch in ("'", '"'):
|
|
36
|
+
in_string = True
|
|
37
|
+
quote = ch
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def find_balanced_end(text: str, start: int) -> int | None:
|
|
41
|
+
"""Return the index just past the bracket matching ``text[start]``.
|
|
42
|
+
|
|
43
|
+
``text[start]`` must be ``{`` or ``[``. Returns ``None`` when the
|
|
44
|
+
structure is truncated (no matching close before end of text).
|
|
45
|
+
"""
|
|
46
|
+
pairs = {"{": "}", "[": "]"}
|
|
47
|
+
stack: list[str] = []
|
|
48
|
+
for i, ch, in_string, _ in iter_chars(text):
|
|
49
|
+
if i < start or in_string:
|
|
50
|
+
continue
|
|
51
|
+
if ch in pairs:
|
|
52
|
+
stack.append(pairs[ch])
|
|
53
|
+
elif ch in ("}", "]"):
|
|
54
|
+
if not stack or stack[-1] != ch:
|
|
55
|
+
return None
|
|
56
|
+
stack.pop()
|
|
57
|
+
if not stack:
|
|
58
|
+
return i + 1
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def unclosed_brackets(text: str) -> list[str]:
|
|
63
|
+
"""Return the closing brackets needed to balance ``text``, in order."""
|
|
64
|
+
pairs = {"{": "}", "[": "]"}
|
|
65
|
+
stack: list[str] = []
|
|
66
|
+
for _, ch, in_string, _ in iter_chars(text):
|
|
67
|
+
if in_string:
|
|
68
|
+
continue
|
|
69
|
+
if ch in pairs:
|
|
70
|
+
stack.append(pairs[ch])
|
|
71
|
+
elif ch in ("}", "]") and stack and stack[-1] == ch:
|
|
72
|
+
stack.pop()
|
|
73
|
+
return list(reversed(stack))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def in_string_at_end(text: str) -> bool:
|
|
77
|
+
"""True when ``text`` ends inside an unterminated string."""
|
|
78
|
+
in_string = False
|
|
79
|
+
quote = ""
|
|
80
|
+
escaped = False
|
|
81
|
+
for ch in text:
|
|
82
|
+
if in_string:
|
|
83
|
+
if escaped:
|
|
84
|
+
escaped = False
|
|
85
|
+
elif ch == "\\":
|
|
86
|
+
escaped = True
|
|
87
|
+
elif ch == quote:
|
|
88
|
+
in_string = False
|
|
89
|
+
elif ch in ("'", '"'):
|
|
90
|
+
in_string = True
|
|
91
|
+
quote = ch
|
|
92
|
+
return in_string
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llmparse-py
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Parse the JSON your LLM meant to send — zero-dependency extraction and repair for raw LLM output.
|
|
5
|
+
Author: gptfei
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/gptfei/llmparse
|
|
8
|
+
Project-URL: Repository, https://github.com/gptfei/llmparse
|
|
9
|
+
Project-URL: Issues, https://github.com/gptfei/llmparse/issues
|
|
10
|
+
Keywords: llm,json,parser,repair,openai,chatgpt,claude,structured-output
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Topic :: Text Processing
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# llmparse
|
|
23
|
+
|
|
24
|
+
**Parse the JSON your LLM meant to send.**
|
|
25
|
+
|
|
26
|
+
LLMs promise JSON and deliver markdown fences, apologies, single
|
|
27
|
+
quotes, trailing commas, Python literals — and, when the token budget
|
|
28
|
+
runs out, JSON that stops mid-string. `llmparse` is a zero-dependency
|
|
29
|
+
Python library that extracts the JSON from raw model output and repairs
|
|
30
|
+
the damage, so `json.loads` stops being the most fragile line in your
|
|
31
|
+
pipeline.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from llmparse import parse
|
|
35
|
+
|
|
36
|
+
parse('Sure! Here you go:\n```json\n{name: "ada", tags: ["x", "y",], ok: True}\n```')
|
|
37
|
+
# {'name': 'ada', 'tags': ['x', 'y'], 'ok': True}
|
|
38
|
+
|
|
39
|
+
parse('{"name": "ada", "tags": ["x", "y"') # truncated stream
|
|
40
|
+
# {'name': 'ada', 'tags': ['x', 'y']}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install llmparse-py # once published to PyPI
|
|
47
|
+
pip install git+https://github.com/gptfei/llmparse
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Python 3.9+. No third-party dependencies, ever.
|
|
51
|
+
|
|
52
|
+
## What it fixes
|
|
53
|
+
|
|
54
|
+
| Damage in LLM output | Example |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| Markdown fences | ` ```json ... ``` ` |
|
|
57
|
+
| Prose around the JSON | `Here is the result: {...} hope it helps` |
|
|
58
|
+
| Single-quoted strings | `{'a': 'b'}` |
|
|
59
|
+
| Unquoted keys | `{name: "ada"}` |
|
|
60
|
+
| Python / JS literals | `True`, `None`, `undefined`, `NaN` |
|
|
61
|
+
| Comments | `{"a": 1} // note` |
|
|
62
|
+
| Trailing commas | `{"a": [1, 2,]}` |
|
|
63
|
+
| Truncation | `{"msg": "hello wor` → closed and parsed |
|
|
64
|
+
|
|
65
|
+
Brackets and comment markers *inside* string values are never touched.
|
|
66
|
+
|
|
67
|
+
## API
|
|
68
|
+
|
|
69
|
+
- `parse(text, default=...)` — return the recovered Python object.
|
|
70
|
+
Raises `LLMParseError` when nothing is recoverable, unless `default`
|
|
71
|
+
is given. `loads` is an alias.
|
|
72
|
+
- `extract(text)` — return the first recoverable JSON substring, or `None`.
|
|
73
|
+
- `repair(text)` — return repaired JSON *text* (for when you need the
|
|
74
|
+
string, not the object).
|
|
75
|
+
|
|
76
|
+
Valid JSON is returned untouched: every repair rule is a no-op on
|
|
77
|
+
input that already parses.
|
|
78
|
+
|
|
79
|
+
## Contributing
|
|
80
|
+
|
|
81
|
+
The repair pipeline is a list of small, independent rules — adding one
|
|
82
|
+
is the easiest meaningful contribution in open source right now:
|
|
83
|
+
|
|
84
|
+
1. Find a way an LLM broke JSON that llmparse does not fix yet.
|
|
85
|
+
2. Write the rule in `src/llmparse/repair.py`, register it in `RULES`.
|
|
86
|
+
3. Add a test with the broken input.
|
|
87
|
+
|
|
88
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and issues labelled
|
|
89
|
+
`good first issue`.
|
|
90
|
+
|
|
91
|
+
## 中文简介
|
|
92
|
+
|
|
93
|
+
大模型说好返回 JSON,实际经常带 markdown 围栏、解释文字、单引号、
|
|
94
|
+
尾随逗号、Python 的 `True/None`,甚至输出到一半被截断。`llmparse`
|
|
95
|
+
是一个零依赖的 Python 库:从原始输出里提取 JSON、逐项修复这些损坏、
|
|
96
|
+
再解析成 Python 对象。合法 JSON 不会被改动。
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/llmparse/__init__.py
|
|
5
|
+
src/llmparse/core.py
|
|
6
|
+
src/llmparse/repair.py
|
|
7
|
+
src/llmparse/scanner.py
|
|
8
|
+
src/llmparse_py.egg-info/PKG-INFO
|
|
9
|
+
src/llmparse_py.egg-info/SOURCES.txt
|
|
10
|
+
src/llmparse_py.egg-info/dependency_links.txt
|
|
11
|
+
src/llmparse_py.egg-info/top_level.txt
|
|
12
|
+
tests/test_parse.py
|
|
13
|
+
tests/test_rules.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
llmparse
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from llmparse import LLMParseError, extract, parse, repair
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_plain_json():
|
|
7
|
+
assert parse('{"a": 1, "b": [1, 2]}') == {"a": 1, "b": [1, 2]}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_top_level_array():
|
|
11
|
+
assert parse('[1, 2, 3]') == [1, 2, 3]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_code_fence():
|
|
15
|
+
text = 'Here is the result:\n```json\n{"name": "ada", "age": 36}\n```\nHope that helps!'
|
|
16
|
+
assert parse(text) == {"name": "ada", "age": 36}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_prose_around_json():
|
|
20
|
+
assert parse('Sure! The answer is {"x": 42} as requested.') == {"x": 42}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_single_quotes():
|
|
24
|
+
assert parse("{'a': 'it works'}") == {"a": "it works"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_unquoted_keys():
|
|
28
|
+
assert parse('{name: "ada", score: 9}') == {"name": "ada", "score": 9}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_python_literals():
|
|
32
|
+
text = '{"ok": True, "bad": False, "missing": None}'
|
|
33
|
+
assert parse(text) == {"ok": True, "bad": False, "missing": None}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_undefined_becomes_null():
|
|
37
|
+
assert parse('{"a": undefined}') == {"a": None}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_trailing_commas():
|
|
41
|
+
assert parse('{"a": 1, "b": [1, 2,],}') == {"a": 1, "b": [1, 2]}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_comments_removed_but_not_in_strings():
|
|
45
|
+
text = '{"url": "https://example.com/a//b", /* note */ "n": 1} // done'
|
|
46
|
+
assert parse(text) == {"url": "https://example.com/a//b", "n": 1}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_truncated_object():
|
|
50
|
+
assert parse('{"name": "ada", "tags": ["x", "y"') == {"name": "ada", "tags": ["x", "y"]}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_truncated_string():
|
|
54
|
+
assert parse('{"msg": "hello wor') == {"msg": "hello wor"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_braces_inside_strings_are_not_structure():
|
|
58
|
+
assert parse('{"a": "use } and ] carefully"}') == {"a": "use } and ] carefully"}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_escaped_quotes_survive():
|
|
62
|
+
assert parse('{"a": "she said \\"hi\\""}') == {"a": 'she said "hi"'}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_first_json_wins():
|
|
66
|
+
assert parse('{"a": 1} and also {"b": 2}') == {"a": 1}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_no_json_raises():
|
|
70
|
+
with pytest.raises(LLMParseError):
|
|
71
|
+
parse("there is no json here at all")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_default_returned_instead_of_raising():
|
|
75
|
+
assert parse("nothing here", default={}) == {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_already_parsed_passthrough():
|
|
79
|
+
assert parse({"a": 1}) == {"a": 1}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_extract_returns_substring():
|
|
83
|
+
assert extract('prose {"a": 1} more') == '{"a": 1}'
|
|
84
|
+
assert extract("no json") is None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_repair_produces_valid_json_text():
|
|
88
|
+
import json
|
|
89
|
+
|
|
90
|
+
assert json.loads(repair("{a: 1,}")) == {"a": 1}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Each repair rule must fix its own damage and leave valid JSON untouched."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from llmparse.repair import (
|
|
6
|
+
close_truncated,
|
|
7
|
+
fix_literals,
|
|
8
|
+
quote_unquoted_keys,
|
|
9
|
+
remove_comments,
|
|
10
|
+
remove_trailing_commas,
|
|
11
|
+
single_to_double_quotes,
|
|
12
|
+
strip_code_fences,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
VALID = '{"a": "text with, comma and ] bracket", "b": [1, 2]}'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_rules_do_not_change_valid_json():
|
|
19
|
+
for rule in (
|
|
20
|
+
remove_comments,
|
|
21
|
+
single_to_double_quotes,
|
|
22
|
+
fix_literals,
|
|
23
|
+
quote_unquoted_keys,
|
|
24
|
+
remove_trailing_commas,
|
|
25
|
+
):
|
|
26
|
+
assert json.loads(rule(VALID)) == json.loads(VALID), rule.__name__
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_strip_code_fences():
|
|
30
|
+
assert strip_code_fences("```json\n{\"a\": 1}\n```") == '{"a": 1}'
|
|
31
|
+
assert strip_code_fences('{"a": 1}') == '{"a": 1}'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_close_truncated_nested():
|
|
35
|
+
assert close_truncated('{"a": [1, {"b": 2') == '{"a": [1, {"b": 2}]}'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_fix_literals_ignores_strings():
|
|
39
|
+
assert fix_literals('{"a": "True story", "b": True}') == '{"a": "True story", "b": true}'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_quote_unquoted_keys_ignores_strings():
|
|
43
|
+
assert quote_unquoted_keys('{"a": "{b: 1}", c: 2}') == '{"a": "{b: 1}", "c": 2}'
|