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.
@@ -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.*
@@ -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`
@@ -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,3 @@
1
+ from .core import LetsJSON, LetsJSONGenerationError, LetsJSONValidationError
2
+
3
+ __all__ = ["LetsJSON", "LetsJSONGenerationError", "LetsJSONValidationError"]
@@ -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"]