letsjson 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.
- letsjson-0.1.0/.gitignore +34 -0
- letsjson-0.1.0/PKG-INFO +41 -0
- letsjson-0.1.0/README.md +33 -0
- letsjson-0.1.0/letsjson/__init__.py +3 -0
- letsjson-0.1.0/letsjson/core.py +212 -0
- letsjson-0.1.0/pyproject.toml +21 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Python cache
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
|
|
7
|
+
# Build artifacts
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
*.egg-info/
|
|
11
|
+
.eggs/
|
|
12
|
+
|
|
13
|
+
# Test and coverage
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.coverage
|
|
16
|
+
.coverage.*
|
|
17
|
+
htmlcov/
|
|
18
|
+
|
|
19
|
+
# Virtual environments
|
|
20
|
+
.venv/
|
|
21
|
+
venv/
|
|
22
|
+
env/
|
|
23
|
+
|
|
24
|
+
# UV lockfile and local config
|
|
25
|
+
uv.lock
|
|
26
|
+
|
|
27
|
+
# IDE and OS files
|
|
28
|
+
.DS_Store
|
|
29
|
+
.idea/
|
|
30
|
+
.vscode/
|
|
31
|
+
|
|
32
|
+
# Local environment files
|
|
33
|
+
.env
|
|
34
|
+
.env.*
|
letsjson-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: letsjson
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate JSON that strictly matches a schema with automatic retries.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: openai>=1.0.0
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# LetsJSON
|
|
10
|
+
|
|
11
|
+
让模型输出强约束 JSON:
|
|
12
|
+
- 按 schema 校验字段与类型
|
|
13
|
+
- 不符合时自动重试(默认 3 次)
|
|
14
|
+
- 超过重试次数仍失败则抛错
|
|
15
|
+
|
|
16
|
+
## 使用(uv)
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv sync
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from openai import OpenAI
|
|
24
|
+
from letsjson import LetsJSON
|
|
25
|
+
|
|
26
|
+
client = OpenAI() # 自动读取 OPENAI_API_KEY
|
|
27
|
+
generator = LetsJSON(client, repeat=3) # repeat 可选,默认 3
|
|
28
|
+
|
|
29
|
+
result = generator.gen(
|
|
30
|
+
"把西红柿炒蛋任务分解最后一个任务是int1",
|
|
31
|
+
{"step1": str, "step2": str, "step3": int},
|
|
32
|
+
)
|
|
33
|
+
print(result)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 行为
|
|
37
|
+
|
|
38
|
+
- 返回值:`dict`
|
|
39
|
+
- 必须和 schema 键完全一致(不允许缺失或多余)
|
|
40
|
+
- 类型严格校验(例如 `int` 不接受 `bool`)
|
|
41
|
+
- 所有尝试失败后抛出 `LetsJSONGenerationError`
|
letsjson-0.1.0/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# LetsJSON
|
|
2
|
+
|
|
3
|
+
让模型输出强约束 JSON:
|
|
4
|
+
- 按 schema 校验字段与类型
|
|
5
|
+
- 不符合时自动重试(默认 3 次)
|
|
6
|
+
- 超过重试次数仍失败则抛错
|
|
7
|
+
|
|
8
|
+
## 使用(uv)
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
uv sync
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from openai import OpenAI
|
|
16
|
+
from letsjson import LetsJSON
|
|
17
|
+
|
|
18
|
+
client = OpenAI() # 自动读取 OPENAI_API_KEY
|
|
19
|
+
generator = LetsJSON(client, repeat=3) # repeat 可选,默认 3
|
|
20
|
+
|
|
21
|
+
result = generator.gen(
|
|
22
|
+
"把西红柿炒蛋任务分解最后一个任务是int1",
|
|
23
|
+
{"step1": str, "step2": str, "step3": int},
|
|
24
|
+
)
|
|
25
|
+
print(result)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 行为
|
|
29
|
+
|
|
30
|
+
- 返回值:`dict`
|
|
31
|
+
- 必须和 schema 键完全一致(不允许缺失或多余)
|
|
32
|
+
- 类型严格校验(例如 `int` 不接受 `bool`)
|
|
33
|
+
- 所有尝试失败后抛出 `LetsJSONGenerationError`
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LetsJSONError(Exception):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LetsJSONValidationError(LetsJSONError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LetsJSONGenerationError(LetsJSONError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LetsJSON:
|
|
21
|
+
def __init__(self, client: Any, repeat: int = 3, model: str = "gpt-4.1-mini") -> None:
|
|
22
|
+
if repeat < 1:
|
|
23
|
+
raise ValueError("repeat must be >= 1")
|
|
24
|
+
self.client = client
|
|
25
|
+
self.repeat = repeat
|
|
26
|
+
self.model = model
|
|
27
|
+
|
|
28
|
+
def gen(self, prompt: str, schema: dict[str, Any]) -> dict[str, Any]:
|
|
29
|
+
if not isinstance(schema, dict):
|
|
30
|
+
raise TypeError("schema must be a dict")
|
|
31
|
+
|
|
32
|
+
last_error: Exception | None = None
|
|
33
|
+
for attempt in range(1, self.repeat + 1):
|
|
34
|
+
full_prompt = self._build_prompt(prompt, schema, attempt, last_error)
|
|
35
|
+
try:
|
|
36
|
+
raw = self._call_model(full_prompt)
|
|
37
|
+
data = self._parse_json(raw)
|
|
38
|
+
self._validate(data, schema)
|
|
39
|
+
return data
|
|
40
|
+
except Exception as exc: # noqa: BLE001
|
|
41
|
+
last_error = exc
|
|
42
|
+
|
|
43
|
+
raise LetsJSONGenerationError(
|
|
44
|
+
f"Failed to generate valid JSON after {self.repeat} attempts. "
|
|
45
|
+
f"Last error: {last_error}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def _build_prompt(
|
|
49
|
+
self, prompt: str, schema: dict[str, Any], attempt: int, last_error: Exception | None
|
|
50
|
+
) -> str:
|
|
51
|
+
schema_text = self._schema_to_text(schema)
|
|
52
|
+
fix_hint = ""
|
|
53
|
+
if last_error is not None:
|
|
54
|
+
fix_hint = f"\nPrevious output was invalid: {last_error}\nPlease fix it."
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
"Return ONLY a valid JSON object with no markdown, no explanation.\n"
|
|
58
|
+
f"User request: {prompt}\n"
|
|
59
|
+
f"Required JSON schema: {schema_text}\n"
|
|
60
|
+
f"Attempt: {attempt}\n"
|
|
61
|
+
f"{fix_hint}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def _call_model(self, prompt: str) -> str:
|
|
65
|
+
responses = getattr(self.client, "responses", None)
|
|
66
|
+
if responses is not None and hasattr(responses, "create"):
|
|
67
|
+
result = responses.create(model=self.model, input=prompt)
|
|
68
|
+
text = getattr(result, "output_text", None)
|
|
69
|
+
if isinstance(text, str) and text.strip():
|
|
70
|
+
return text
|
|
71
|
+
|
|
72
|
+
# Fallback: collect text chunks from response output structure.
|
|
73
|
+
output = getattr(result, "output", None) or []
|
|
74
|
+
chunks: list[str] = []
|
|
75
|
+
for item in output:
|
|
76
|
+
content = getattr(item, "content", None) or []
|
|
77
|
+
for part in content:
|
|
78
|
+
part_text = getattr(part, "text", None)
|
|
79
|
+
if isinstance(part_text, str):
|
|
80
|
+
chunks.append(part_text)
|
|
81
|
+
if chunks:
|
|
82
|
+
return "\n".join(chunks)
|
|
83
|
+
|
|
84
|
+
chat = getattr(self.client, "chat", None)
|
|
85
|
+
completions = getattr(chat, "completions", None) if chat is not None else None
|
|
86
|
+
if completions is not None and hasattr(completions, "create"):
|
|
87
|
+
result = completions.create(
|
|
88
|
+
model=self.model, messages=[{"role": "user", "content": prompt}]
|
|
89
|
+
)
|
|
90
|
+
choices = getattr(result, "choices", None) or []
|
|
91
|
+
if choices:
|
|
92
|
+
message = getattr(choices[0], "message", None)
|
|
93
|
+
content = getattr(message, "content", "")
|
|
94
|
+
if isinstance(content, str):
|
|
95
|
+
return content
|
|
96
|
+
|
|
97
|
+
raise LetsJSONGenerationError(
|
|
98
|
+
"Unsupported client: expected OpenAI client with responses.create or "
|
|
99
|
+
"chat.completions.create."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _parse_json(self, text: str) -> Any:
|
|
103
|
+
text = text.strip()
|
|
104
|
+
try:
|
|
105
|
+
return json.loads(text)
|
|
106
|
+
except json.JSONDecodeError:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
for candidate in self._extract_json_candidates(text):
|
|
110
|
+
try:
|
|
111
|
+
return json.loads(candidate)
|
|
112
|
+
except json.JSONDecodeError:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
raise LetsJSONValidationError("Model output is not valid JSON.")
|
|
116
|
+
|
|
117
|
+
def _extract_json_candidates(self, text: str) -> list[str]:
|
|
118
|
+
candidates: list[str] = []
|
|
119
|
+
|
|
120
|
+
fenced = re.findall(r"```(?:json)?\s*(.*?)\s*```", text, flags=re.DOTALL | re.IGNORECASE)
|
|
121
|
+
candidates.extend(fenced)
|
|
122
|
+
|
|
123
|
+
stack: list[str] = []
|
|
124
|
+
start = -1
|
|
125
|
+
for idx, ch in enumerate(text):
|
|
126
|
+
if ch in "{[":
|
|
127
|
+
if not stack:
|
|
128
|
+
start = idx
|
|
129
|
+
stack.append(ch)
|
|
130
|
+
elif ch in "}]":
|
|
131
|
+
if not stack:
|
|
132
|
+
continue
|
|
133
|
+
opener = stack.pop()
|
|
134
|
+
if (opener, ch) not in {("{", "}"), ("[", "]")}:
|
|
135
|
+
stack.clear()
|
|
136
|
+
start = -1
|
|
137
|
+
continue
|
|
138
|
+
if not stack and start >= 0:
|
|
139
|
+
candidates.append(text[start : idx + 1])
|
|
140
|
+
start = -1
|
|
141
|
+
return candidates
|
|
142
|
+
|
|
143
|
+
def _schema_to_text(self, spec: Any) -> str:
|
|
144
|
+
if isinstance(spec, dict):
|
|
145
|
+
inner = ", ".join(f'"{k}": {self._schema_to_text(v)}' for k, v in spec.items())
|
|
146
|
+
return f"{{{inner}}}"
|
|
147
|
+
if isinstance(spec, list):
|
|
148
|
+
if len(spec) != 1:
|
|
149
|
+
raise TypeError("List schema must contain exactly one element schema.")
|
|
150
|
+
return f"[{self._schema_to_text(spec[0])}]"
|
|
151
|
+
if isinstance(spec, type):
|
|
152
|
+
return spec.__name__
|
|
153
|
+
raise TypeError(f"Unsupported schema spec: {spec!r}")
|
|
154
|
+
|
|
155
|
+
def _validate(self, data: Any, schema: Any, path: str = "root") -> None:
|
|
156
|
+
if isinstance(schema, dict):
|
|
157
|
+
if not isinstance(data, dict):
|
|
158
|
+
raise LetsJSONValidationError(f"{path} must be an object")
|
|
159
|
+
expected_keys = set(schema.keys())
|
|
160
|
+
actual_keys = set(data.keys())
|
|
161
|
+
missing = expected_keys - actual_keys
|
|
162
|
+
extra = actual_keys - expected_keys
|
|
163
|
+
if missing:
|
|
164
|
+
raise LetsJSONValidationError(f"{path} missing keys: {sorted(missing)}")
|
|
165
|
+
if extra:
|
|
166
|
+
raise LetsJSONValidationError(f"{path} has unexpected keys: {sorted(extra)}")
|
|
167
|
+
for key, sub_schema in schema.items():
|
|
168
|
+
self._validate(data[key], sub_schema, f"{path}.{key}")
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
if isinstance(schema, list):
|
|
172
|
+
if len(schema) != 1:
|
|
173
|
+
raise TypeError("List schema must contain exactly one element schema.")
|
|
174
|
+
if not isinstance(data, list):
|
|
175
|
+
raise LetsJSONValidationError(f"{path} must be a list")
|
|
176
|
+
for idx, item in enumerate(data):
|
|
177
|
+
self._validate(item, schema[0], f"{path}[{idx}]")
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
if isinstance(schema, type):
|
|
181
|
+
if schema is int:
|
|
182
|
+
if not (isinstance(data, int) and not isinstance(data, bool)):
|
|
183
|
+
raise LetsJSONValidationError(f"{path} must be int")
|
|
184
|
+
return
|
|
185
|
+
if schema is float:
|
|
186
|
+
if not (
|
|
187
|
+
(isinstance(data, float) and not isinstance(data, bool))
|
|
188
|
+
or (isinstance(data, int) and not isinstance(data, bool))
|
|
189
|
+
):
|
|
190
|
+
raise LetsJSONValidationError(f"{path} must be float")
|
|
191
|
+
return
|
|
192
|
+
if schema is bool:
|
|
193
|
+
if not isinstance(data, bool):
|
|
194
|
+
raise LetsJSONValidationError(f"{path} must be bool")
|
|
195
|
+
return
|
|
196
|
+
if schema is str:
|
|
197
|
+
if not isinstance(data, str):
|
|
198
|
+
raise LetsJSONValidationError(f"{path} must be str")
|
|
199
|
+
return
|
|
200
|
+
if schema is list:
|
|
201
|
+
if not isinstance(data, list):
|
|
202
|
+
raise LetsJSONValidationError(f"{path} must be list")
|
|
203
|
+
return
|
|
204
|
+
if schema is dict:
|
|
205
|
+
if not isinstance(data, dict):
|
|
206
|
+
raise LetsJSONValidationError(f"{path} must be object")
|
|
207
|
+
return
|
|
208
|
+
if not isinstance(data, schema):
|
|
209
|
+
raise LetsJSONValidationError(f"{path} must be {schema.__name__}")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
raise TypeError(f"Unsupported schema spec at {path}: {schema!r}")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "letsjson"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Generate JSON that strictly matches a schema with automatic retries."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"openai>=1.0.0",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[dependency-groups]
|
|
12
|
+
dev = [
|
|
13
|
+
"pytest>=8.0.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["letsjson"]
|