structured-llm 0.1.0__py3-none-any.whl
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.
- structured_llm/__init__.py +22 -0
- structured_llm/client.py +459 -0
- structured_llm/errors.py +54 -0
- structured_llm/parser.py +143 -0
- structured_llm/schema.py +153 -0
- structured_llm-0.1.0.dist-info/METADATA +100 -0
- structured_llm-0.1.0.dist-info/RECORD +8 -0
- structured_llm-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .client import StructuredClient
|
|
2
|
+
from .errors import (
|
|
3
|
+
ProviderError,
|
|
4
|
+
StructuredLLMError,
|
|
5
|
+
StructuredParseError,
|
|
6
|
+
StructuredValidationError,
|
|
7
|
+
UnsupportedSchemaError,
|
|
8
|
+
)
|
|
9
|
+
from .parser import parse_structured_text
|
|
10
|
+
from .schema import build_schema_spec
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ProviderError",
|
|
14
|
+
"StructuredClient",
|
|
15
|
+
"StructuredLLMError",
|
|
16
|
+
"StructuredParseError",
|
|
17
|
+
"StructuredValidationError",
|
|
18
|
+
"UnsupportedSchemaError",
|
|
19
|
+
"build_schema_spec",
|
|
20
|
+
"parse_structured_text",
|
|
21
|
+
]
|
|
22
|
+
|
structured_llm/client.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, Literal, TypeVar, overload
|
|
8
|
+
|
|
9
|
+
from .errors import ProviderError, StructuredLLMError, StructuredValidationError
|
|
10
|
+
from .parser import parse_structured_text
|
|
11
|
+
from .schema import SchemaSpec, build_schema_spec
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
Mode = Literal["auto", "native", "prompt"]
|
|
15
|
+
Endpoint = Literal["chat", "responses"]
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StructuredClient:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
model: str,
|
|
24
|
+
api_key: str | None = None,
|
|
25
|
+
base_url: str | None = None,
|
|
26
|
+
mode: Mode = "prompt",
|
|
27
|
+
endpoint: Endpoint = "chat",
|
|
28
|
+
max_retries: int = 1,
|
|
29
|
+
debug: bool = False,
|
|
30
|
+
openai_client: Any | None = None,
|
|
31
|
+
async_openai_client: Any | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
if mode not in {"auto", "native", "prompt"}:
|
|
34
|
+
raise ValueError("mode must be 'auto', 'native', or 'prompt'")
|
|
35
|
+
if endpoint not in {"chat", "responses"}:
|
|
36
|
+
raise ValueError("endpoint must be 'chat' or 'responses'")
|
|
37
|
+
if max_retries < 0:
|
|
38
|
+
raise ValueError("max_retries must be >= 0")
|
|
39
|
+
|
|
40
|
+
self.model = model
|
|
41
|
+
self.api_key = _value_or_env(api_key, "OPENAI_API_KEY")
|
|
42
|
+
self.base_url = _value_or_env(base_url, "OPENAI_BASE_URL")
|
|
43
|
+
self.mode = mode
|
|
44
|
+
self.endpoint = endpoint
|
|
45
|
+
self.max_retries = max_retries
|
|
46
|
+
self.debug = debug
|
|
47
|
+
self._client = openai_client
|
|
48
|
+
self._async_client = async_openai_client
|
|
49
|
+
|
|
50
|
+
@overload
|
|
51
|
+
def run(
|
|
52
|
+
self,
|
|
53
|
+
prompt: str,
|
|
54
|
+
schema: type[T],
|
|
55
|
+
*,
|
|
56
|
+
system: str | None = None,
|
|
57
|
+
**model_options: Any,
|
|
58
|
+
) -> T: ...
|
|
59
|
+
|
|
60
|
+
@overload
|
|
61
|
+
def run(
|
|
62
|
+
self,
|
|
63
|
+
prompt: str,
|
|
64
|
+
schema: Any,
|
|
65
|
+
*,
|
|
66
|
+
system: str | None = None,
|
|
67
|
+
**model_options: Any,
|
|
68
|
+
) -> Any: ...
|
|
69
|
+
|
|
70
|
+
def run(
|
|
71
|
+
self,
|
|
72
|
+
prompt: str,
|
|
73
|
+
schema: Any,
|
|
74
|
+
*,
|
|
75
|
+
system: str | None = None,
|
|
76
|
+
**model_options: Any,
|
|
77
|
+
) -> Any:
|
|
78
|
+
spec = build_schema_spec(schema)
|
|
79
|
+
raw_text = self._complete(prompt, spec, system=system, **model_options)
|
|
80
|
+
return self._parse_with_retry(raw_text, spec, prompt=prompt, system=system, model_options=model_options)
|
|
81
|
+
|
|
82
|
+
@overload
|
|
83
|
+
async def arun(
|
|
84
|
+
self,
|
|
85
|
+
prompt: str,
|
|
86
|
+
schema: type[T],
|
|
87
|
+
*,
|
|
88
|
+
system: str | None = None,
|
|
89
|
+
**model_options: Any,
|
|
90
|
+
) -> T: ...
|
|
91
|
+
|
|
92
|
+
@overload
|
|
93
|
+
async def arun(
|
|
94
|
+
self,
|
|
95
|
+
prompt: str,
|
|
96
|
+
schema: Any,
|
|
97
|
+
*,
|
|
98
|
+
system: str | None = None,
|
|
99
|
+
**model_options: Any,
|
|
100
|
+
) -> Any: ...
|
|
101
|
+
|
|
102
|
+
async def arun(
|
|
103
|
+
self,
|
|
104
|
+
prompt: str,
|
|
105
|
+
schema: Any,
|
|
106
|
+
*,
|
|
107
|
+
system: str | None = None,
|
|
108
|
+
**model_options: Any,
|
|
109
|
+
) -> Any:
|
|
110
|
+
spec = build_schema_spec(schema)
|
|
111
|
+
raw_text = await self._acomplete(prompt, spec, system=system, **model_options)
|
|
112
|
+
return await self._aparse_with_retry(
|
|
113
|
+
raw_text,
|
|
114
|
+
spec,
|
|
115
|
+
prompt=prompt,
|
|
116
|
+
system=system,
|
|
117
|
+
model_options=model_options,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@overload
|
|
121
|
+
def parse(self, text: str, schema: type[T], *, repair: bool = True) -> T: ...
|
|
122
|
+
|
|
123
|
+
@overload
|
|
124
|
+
def parse(self, text: str, schema: Any, *, repair: bool = True) -> Any: ...
|
|
125
|
+
|
|
126
|
+
def parse(self, text: str, schema: Any, *, repair: bool = True) -> Any:
|
|
127
|
+
return parse_structured_text(text, schema, repair=repair)
|
|
128
|
+
|
|
129
|
+
def _complete(
|
|
130
|
+
self,
|
|
131
|
+
prompt: str,
|
|
132
|
+
spec: SchemaSpec,
|
|
133
|
+
*,
|
|
134
|
+
system: str | None,
|
|
135
|
+
**model_options: Any,
|
|
136
|
+
) -> str:
|
|
137
|
+
if self.mode == "prompt":
|
|
138
|
+
return self._call_prompt(prompt, spec, system=system, **model_options)
|
|
139
|
+
if self.mode == "native":
|
|
140
|
+
return self._call_native(prompt, spec, system=system, **model_options)
|
|
141
|
+
try:
|
|
142
|
+
return self._call_native(prompt, spec, system=system, **model_options)
|
|
143
|
+
except ProviderError as exc:
|
|
144
|
+
if not _looks_like_unsupported_native(exc):
|
|
145
|
+
raise
|
|
146
|
+
return self._call_prompt(prompt, spec, system=system, **model_options)
|
|
147
|
+
|
|
148
|
+
async def _acomplete(
|
|
149
|
+
self,
|
|
150
|
+
prompt: str,
|
|
151
|
+
spec: SchemaSpec,
|
|
152
|
+
*,
|
|
153
|
+
system: str | None,
|
|
154
|
+
**model_options: Any,
|
|
155
|
+
) -> str:
|
|
156
|
+
if self.mode == "prompt":
|
|
157
|
+
return await self._acall_prompt(prompt, spec, system=system, **model_options)
|
|
158
|
+
if self.mode == "native":
|
|
159
|
+
return await self._acall_native(prompt, spec, system=system, **model_options)
|
|
160
|
+
try:
|
|
161
|
+
return await self._acall_native(prompt, spec, system=system, **model_options)
|
|
162
|
+
except ProviderError as exc:
|
|
163
|
+
if not _looks_like_unsupported_native(exc):
|
|
164
|
+
raise
|
|
165
|
+
return await self._acall_prompt(prompt, spec, system=system, **model_options)
|
|
166
|
+
|
|
167
|
+
def _parse_with_retry(
|
|
168
|
+
self,
|
|
169
|
+
raw_text: str,
|
|
170
|
+
spec: SchemaSpec,
|
|
171
|
+
*,
|
|
172
|
+
prompt: str,
|
|
173
|
+
system: str | None,
|
|
174
|
+
model_options: dict[str, Any],
|
|
175
|
+
) -> Any:
|
|
176
|
+
try:
|
|
177
|
+
return parse_structured_text(raw_text, spec)
|
|
178
|
+
except StructuredValidationError as exc:
|
|
179
|
+
if self.max_retries <= 0:
|
|
180
|
+
raise
|
|
181
|
+
retry_prompt = _retry_prompt(prompt, spec, exc)
|
|
182
|
+
retry_text = self._call_prompt(retry_prompt, spec, system=system, **model_options)
|
|
183
|
+
return parse_structured_text(retry_text, spec)
|
|
184
|
+
|
|
185
|
+
async def _aparse_with_retry(
|
|
186
|
+
self,
|
|
187
|
+
raw_text: str,
|
|
188
|
+
spec: SchemaSpec,
|
|
189
|
+
*,
|
|
190
|
+
prompt: str,
|
|
191
|
+
system: str | None,
|
|
192
|
+
model_options: dict[str, Any],
|
|
193
|
+
) -> Any:
|
|
194
|
+
try:
|
|
195
|
+
return parse_structured_text(raw_text, spec)
|
|
196
|
+
except StructuredValidationError as exc:
|
|
197
|
+
if self.max_retries <= 0:
|
|
198
|
+
raise
|
|
199
|
+
retry_prompt = _retry_prompt(prompt, spec, exc)
|
|
200
|
+
retry_text = await self._acall_prompt(retry_prompt, spec, system=system, **model_options)
|
|
201
|
+
return parse_structured_text(retry_text, spec)
|
|
202
|
+
|
|
203
|
+
def _call_native(self, prompt: str, spec: SchemaSpec, *, system: str | None, **model_options: Any) -> str:
|
|
204
|
+
client = self._sync_client()
|
|
205
|
+
try:
|
|
206
|
+
response = self._invoke_sync(client, prompt, spec, system=system, native=True, **model_options)
|
|
207
|
+
raw_text = _extract_response_text(response)
|
|
208
|
+
self._debug_raw_output(raw_text)
|
|
209
|
+
return raw_text
|
|
210
|
+
except StructuredLLMError:
|
|
211
|
+
raise
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
raise ProviderError("Provider native structured output call failed", cause=exc) from exc
|
|
214
|
+
|
|
215
|
+
def _call_prompt(self, prompt: str, spec: SchemaSpec, *, system: str | None, **model_options: Any) -> str:
|
|
216
|
+
client = self._sync_client()
|
|
217
|
+
prompt = _prompt_with_schema(prompt, spec)
|
|
218
|
+
try:
|
|
219
|
+
response = self._invoke_sync(client, prompt, spec, system=system, native=False, **model_options)
|
|
220
|
+
raw_text = _extract_response_text(response)
|
|
221
|
+
self._debug_raw_output(raw_text)
|
|
222
|
+
return raw_text
|
|
223
|
+
except StructuredLLMError:
|
|
224
|
+
raise
|
|
225
|
+
except Exception as exc:
|
|
226
|
+
raise ProviderError("Provider prompt structured output call failed", cause=exc) from exc
|
|
227
|
+
|
|
228
|
+
async def _acall_native(self, prompt: str, spec: SchemaSpec, *, system: str | None, **model_options: Any) -> str:
|
|
229
|
+
client = self._async_openai_client()
|
|
230
|
+
try:
|
|
231
|
+
response = await self._invoke_async(client, prompt, spec, system=system, native=True, **model_options)
|
|
232
|
+
raw_text = _extract_response_text(response)
|
|
233
|
+
self._debug_raw_output(raw_text)
|
|
234
|
+
return raw_text
|
|
235
|
+
except StructuredLLMError:
|
|
236
|
+
raise
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
raise ProviderError("Provider native structured output call failed", cause=exc) from exc
|
|
239
|
+
|
|
240
|
+
async def _acall_prompt(self, prompt: str, spec: SchemaSpec, *, system: str | None, **model_options: Any) -> str:
|
|
241
|
+
client = self._async_openai_client()
|
|
242
|
+
prompt = _prompt_with_schema(prompt, spec)
|
|
243
|
+
try:
|
|
244
|
+
response = await self._invoke_async(client, prompt, spec, system=system, native=False, **model_options)
|
|
245
|
+
raw_text = _extract_response_text(response)
|
|
246
|
+
self._debug_raw_output(raw_text)
|
|
247
|
+
return raw_text
|
|
248
|
+
except StructuredLLMError:
|
|
249
|
+
raise
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
raise ProviderError("Provider prompt structured output call failed", cause=exc) from exc
|
|
252
|
+
|
|
253
|
+
def _invoke_sync(
|
|
254
|
+
self,
|
|
255
|
+
client: Any,
|
|
256
|
+
prompt: str,
|
|
257
|
+
spec: SchemaSpec,
|
|
258
|
+
*,
|
|
259
|
+
system: str | None,
|
|
260
|
+
native: bool,
|
|
261
|
+
**model_options: Any,
|
|
262
|
+
) -> Any:
|
|
263
|
+
if self.endpoint == "responses":
|
|
264
|
+
kwargs = self._responses_kwargs(prompt, spec, system=system, native=native, **model_options)
|
|
265
|
+
self._debug_request("responses.create", kwargs)
|
|
266
|
+
return client.responses.create(**kwargs)
|
|
267
|
+
kwargs = self._chat_kwargs(prompt, spec, system=system, native=native, **model_options)
|
|
268
|
+
self._debug_request("chat.completions.create", kwargs)
|
|
269
|
+
return client.chat.completions.create(**kwargs)
|
|
270
|
+
|
|
271
|
+
async def _invoke_async(
|
|
272
|
+
self,
|
|
273
|
+
client: Any,
|
|
274
|
+
prompt: str,
|
|
275
|
+
spec: SchemaSpec,
|
|
276
|
+
*,
|
|
277
|
+
system: str | None,
|
|
278
|
+
native: bool,
|
|
279
|
+
**model_options: Any,
|
|
280
|
+
) -> Any:
|
|
281
|
+
if self.endpoint == "responses":
|
|
282
|
+
kwargs = self._responses_kwargs(prompt, spec, system=system, native=native, **model_options)
|
|
283
|
+
self._debug_request("responses.create", kwargs)
|
|
284
|
+
return await _maybe_await(client.responses.create(**kwargs))
|
|
285
|
+
kwargs = self._chat_kwargs(prompt, spec, system=system, native=native, **model_options)
|
|
286
|
+
self._debug_request("chat.completions.create", kwargs)
|
|
287
|
+
return await _maybe_await(client.chat.completions.create(**kwargs))
|
|
288
|
+
|
|
289
|
+
def _chat_kwargs(
|
|
290
|
+
self,
|
|
291
|
+
prompt: str,
|
|
292
|
+
spec: SchemaSpec,
|
|
293
|
+
*,
|
|
294
|
+
system: str | None,
|
|
295
|
+
native: bool,
|
|
296
|
+
**model_options: Any,
|
|
297
|
+
) -> dict[str, Any]:
|
|
298
|
+
messages = []
|
|
299
|
+
if system:
|
|
300
|
+
messages.append({"role": "system", "content": system})
|
|
301
|
+
messages.append({"role": "user", "content": prompt})
|
|
302
|
+
kwargs = {"model": self.model, "messages": messages, **model_options}
|
|
303
|
+
if native:
|
|
304
|
+
kwargs["response_format"] = _chat_response_format(spec)
|
|
305
|
+
return kwargs
|
|
306
|
+
|
|
307
|
+
def _responses_kwargs(
|
|
308
|
+
self,
|
|
309
|
+
prompt: str,
|
|
310
|
+
spec: SchemaSpec,
|
|
311
|
+
*,
|
|
312
|
+
system: str | None,
|
|
313
|
+
native: bool,
|
|
314
|
+
**model_options: Any,
|
|
315
|
+
) -> dict[str, Any]:
|
|
316
|
+
input_text = prompt if system is None else f"{system}\n\n{prompt}"
|
|
317
|
+
kwargs = {"model": self.model, "input": input_text, **model_options}
|
|
318
|
+
if native:
|
|
319
|
+
kwargs["text"] = {"format": _responses_text_format(spec)}
|
|
320
|
+
return kwargs
|
|
321
|
+
|
|
322
|
+
def _sync_client(self) -> Any:
|
|
323
|
+
if self._client is None:
|
|
324
|
+
try:
|
|
325
|
+
from openai import OpenAI
|
|
326
|
+
except ImportError as exc: # pragma: no cover - dependency is declared
|
|
327
|
+
raise ProviderError("openai package is required for provider calls", cause=exc) from exc
|
|
328
|
+
self._client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
|
329
|
+
return self._client
|
|
330
|
+
|
|
331
|
+
def _async_openai_client(self) -> Any:
|
|
332
|
+
if self._async_client is None:
|
|
333
|
+
try:
|
|
334
|
+
from openai import AsyncOpenAI
|
|
335
|
+
except ImportError as exc: # pragma: no cover - dependency is declared
|
|
336
|
+
raise ProviderError("openai package is required for provider calls", cause=exc) from exc
|
|
337
|
+
self._async_client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url)
|
|
338
|
+
return self._async_client
|
|
339
|
+
|
|
340
|
+
def _debug_request(self, endpoint: str, kwargs: dict[str, Any]) -> None:
|
|
341
|
+
if not self.debug:
|
|
342
|
+
return
|
|
343
|
+
print(f"[structured-llm debug] request {endpoint}", file=sys.stderr)
|
|
344
|
+
print(json.dumps(kwargs, ensure_ascii=False, indent=2, default=repr), file=sys.stderr)
|
|
345
|
+
|
|
346
|
+
def _debug_raw_output(self, raw_text: str) -> None:
|
|
347
|
+
if not self.debug:
|
|
348
|
+
return
|
|
349
|
+
print("[structured-llm debug] raw output", file=sys.stderr)
|
|
350
|
+
print(raw_text, file=sys.stderr)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _chat_response_format(spec: SchemaSpec) -> dict[str, Any]:
|
|
354
|
+
return {
|
|
355
|
+
"type": "json_schema",
|
|
356
|
+
"json_schema": {
|
|
357
|
+
"name": spec.name,
|
|
358
|
+
"strict": True,
|
|
359
|
+
"schema": spec.native_json_schema,
|
|
360
|
+
},
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _responses_text_format(spec: SchemaSpec) -> dict[str, Any]:
|
|
365
|
+
return {
|
|
366
|
+
"type": "json_schema",
|
|
367
|
+
"name": spec.name,
|
|
368
|
+
"strict": True,
|
|
369
|
+
"schema": spec.native_json_schema,
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _prompt_with_schema(prompt: str, spec: SchemaSpec) -> str:
|
|
374
|
+
return (
|
|
375
|
+
f"{prompt.rstrip()}\n\n"
|
|
376
|
+
"Return a JSON value that matches this output format:\n"
|
|
377
|
+
f"{spec.prompt_schema}\n\n"
|
|
378
|
+
"Return only the JSON value. Do not include markdown fences, explanations, or extra text."
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _retry_prompt(prompt: str, spec: SchemaSpec, exc: StructuredValidationError) -> str:
|
|
383
|
+
return (
|
|
384
|
+
f"{prompt.rstrip()}\n\n"
|
|
385
|
+
"The previous response did not match the required JSON schema.\n"
|
|
386
|
+
f"Validation error:\n{exc.validation_error}\n\n"
|
|
387
|
+
"Return corrected JSON only, matching this schema:\n"
|
|
388
|
+
f"{spec.prompt_schema}"
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _looks_like_unsupported_native(exc: ProviderError) -> bool:
|
|
393
|
+
text = f"{exc} {exc.cause}".lower()
|
|
394
|
+
markers = [
|
|
395
|
+
"response_format",
|
|
396
|
+
"json_schema",
|
|
397
|
+
"unsupported",
|
|
398
|
+
"not supported",
|
|
399
|
+
"unknown parameter",
|
|
400
|
+
"invalid parameter",
|
|
401
|
+
"extra fields not permitted",
|
|
402
|
+
]
|
|
403
|
+
return any(marker in text for marker in markers)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _value_or_env(value: str | None, env_name: str) -> str | None:
|
|
407
|
+
if value is not None:
|
|
408
|
+
return value
|
|
409
|
+
return os.environ.get(env_name)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _extract_response_text(response: Any) -> str:
|
|
413
|
+
if isinstance(response, str):
|
|
414
|
+
return response
|
|
415
|
+
output_text = _get_attr_or_item(response, "output_text")
|
|
416
|
+
if isinstance(output_text, str):
|
|
417
|
+
return output_text
|
|
418
|
+
|
|
419
|
+
choices = _get_attr_or_item(response, "choices")
|
|
420
|
+
if choices:
|
|
421
|
+
message = _get_attr_or_item(choices[0], "message")
|
|
422
|
+
content = _get_attr_or_item(message, "content")
|
|
423
|
+
if isinstance(content, str):
|
|
424
|
+
return content
|
|
425
|
+
if isinstance(content, list):
|
|
426
|
+
return "".join(
|
|
427
|
+
part.get("text", "") if isinstance(part, dict) else str(part)
|
|
428
|
+
for part in content
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
output = _get_attr_or_item(response, "output")
|
|
432
|
+
if output:
|
|
433
|
+
texts: list[str] = []
|
|
434
|
+
for item in output:
|
|
435
|
+
content = _get_attr_or_item(item, "content")
|
|
436
|
+
if not content:
|
|
437
|
+
continue
|
|
438
|
+
for part in content:
|
|
439
|
+
text = _get_attr_or_item(part, "text")
|
|
440
|
+
if isinstance(text, str):
|
|
441
|
+
texts.append(text)
|
|
442
|
+
if texts:
|
|
443
|
+
return "".join(texts)
|
|
444
|
+
|
|
445
|
+
raise ProviderError("Provider response did not contain text output")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _get_attr_or_item(obj: Any, key: str) -> Any:
|
|
449
|
+
if obj is None:
|
|
450
|
+
return None
|
|
451
|
+
if isinstance(obj, dict):
|
|
452
|
+
return obj.get(key)
|
|
453
|
+
return getattr(obj, key, None)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
async def _maybe_await(value: Any) -> Any:
|
|
457
|
+
if inspect.isawaitable(value):
|
|
458
|
+
return await value
|
|
459
|
+
return value
|
structured_llm/errors.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StructuredLLMError(Exception):
|
|
7
|
+
"""Base exception for structured_llm."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UnsupportedSchemaError(StructuredLLMError):
|
|
11
|
+
"""Raised when a Python type cannot be converted into a usable schema."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProviderError(StructuredLLMError):
|
|
15
|
+
"""Raised when the model provider call fails."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, *, cause: BaseException | None = None) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.cause = cause
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StructuredParseError(StructuredLLMError):
|
|
23
|
+
"""Raised when no JSON value can be extracted from model output."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
message: str,
|
|
28
|
+
*,
|
|
29
|
+
raw_text: str,
|
|
30
|
+
schema_name: str,
|
|
31
|
+
cause: BaseException | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.raw_text = raw_text
|
|
35
|
+
self.schema_name = schema_name
|
|
36
|
+
self.cause = cause
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StructuredValidationError(StructuredLLMError):
|
|
40
|
+
"""Raised when extracted JSON does not validate against the target schema."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
message: str,
|
|
45
|
+
*,
|
|
46
|
+
raw_text: str,
|
|
47
|
+
schema_name: str,
|
|
48
|
+
validation_error: Any,
|
|
49
|
+
) -> None:
|
|
50
|
+
super().__init__(message)
|
|
51
|
+
self.raw_text = raw_text
|
|
52
|
+
self.schema_name = schema_name
|
|
53
|
+
self.validation_error = validation_error
|
|
54
|
+
|
structured_llm/parser.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, TypeVar, overload
|
|
6
|
+
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from .errors import StructuredParseError, StructuredValidationError
|
|
10
|
+
from .schema import SchemaSpec, build_schema_spec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_FENCED_JSON_RE = re.compile(r"```(?:json|JSON)?\s*(.*?)```", re.DOTALL)
|
|
14
|
+
_TRAILING_COMMA_RE = re.compile(r",(\s*[}\]])")
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@overload
|
|
19
|
+
def parse_structured_text(
|
|
20
|
+
text: str,
|
|
21
|
+
schema: type[T],
|
|
22
|
+
*,
|
|
23
|
+
repair: bool = True,
|
|
24
|
+
schema_name: str | None = None,
|
|
25
|
+
) -> T: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@overload
|
|
29
|
+
def parse_structured_text(
|
|
30
|
+
text: str,
|
|
31
|
+
schema: Any,
|
|
32
|
+
*,
|
|
33
|
+
repair: bool = True,
|
|
34
|
+
schema_name: str | None = None,
|
|
35
|
+
) -> Any: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_structured_text(
|
|
39
|
+
text: str,
|
|
40
|
+
schema: Any,
|
|
41
|
+
*,
|
|
42
|
+
repair: bool = True,
|
|
43
|
+
schema_name: str | None = None,
|
|
44
|
+
) -> Any:
|
|
45
|
+
spec = schema if isinstance(schema, SchemaSpec) else build_schema_spec(schema, name=schema_name)
|
|
46
|
+
value = _extract_json_value(text, repair=repair, schema_name=spec.name)
|
|
47
|
+
try:
|
|
48
|
+
return spec.adapter.validate_python(value)
|
|
49
|
+
except ValidationError as exc:
|
|
50
|
+
raise StructuredValidationError(
|
|
51
|
+
f"Response did not match schema {spec.name}",
|
|
52
|
+
raw_text=text,
|
|
53
|
+
schema_name=spec.name,
|
|
54
|
+
validation_error=exc,
|
|
55
|
+
) from exc
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _extract_json_value(text: str, *, repair: bool, schema_name: str) -> Any:
|
|
59
|
+
candidates = _candidate_json_strings(text)
|
|
60
|
+
last_error: BaseException | None = None
|
|
61
|
+
|
|
62
|
+
for candidate in candidates:
|
|
63
|
+
for variant in _repair_variants(candidate) if repair else (candidate,):
|
|
64
|
+
try:
|
|
65
|
+
return json.loads(variant)
|
|
66
|
+
except json.JSONDecodeError as exc:
|
|
67
|
+
last_error = exc
|
|
68
|
+
|
|
69
|
+
raise StructuredParseError(
|
|
70
|
+
f"No JSON value could be extracted for schema {schema_name}",
|
|
71
|
+
raw_text=text,
|
|
72
|
+
schema_name=schema_name,
|
|
73
|
+
cause=last_error,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _candidate_json_strings(text: str) -> list[str]:
|
|
78
|
+
stripped = text.strip()
|
|
79
|
+
candidates: list[str] = []
|
|
80
|
+
if stripped:
|
|
81
|
+
candidates.append(stripped)
|
|
82
|
+
|
|
83
|
+
for match in _FENCED_JSON_RE.finditer(text):
|
|
84
|
+
block = match.group(1).strip()
|
|
85
|
+
if block:
|
|
86
|
+
candidates.append(block)
|
|
87
|
+
|
|
88
|
+
extracted = _first_balanced_json(text)
|
|
89
|
+
if extracted and extracted not in candidates:
|
|
90
|
+
candidates.append(extracted)
|
|
91
|
+
|
|
92
|
+
return candidates
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _repair_variants(candidate: str) -> tuple[str, ...]:
|
|
96
|
+
stripped = candidate.strip()
|
|
97
|
+
no_trailing_commas = _TRAILING_COMMA_RE.sub(r"\1", stripped)
|
|
98
|
+
variants = [stripped]
|
|
99
|
+
if no_trailing_commas != stripped:
|
|
100
|
+
variants.append(no_trailing_commas)
|
|
101
|
+
return tuple(dict.fromkeys(variants))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _first_balanced_json(text: str) -> str | None:
|
|
105
|
+
for start, char in enumerate(text):
|
|
106
|
+
if char not in "{[":
|
|
107
|
+
continue
|
|
108
|
+
result = _balanced_from(text, start)
|
|
109
|
+
if result is not None:
|
|
110
|
+
return result
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _balanced_from(text: str, start: int) -> str | None:
|
|
115
|
+
opener = text[start]
|
|
116
|
+
closer = "}" if opener == "{" else "]"
|
|
117
|
+
stack = [closer]
|
|
118
|
+
in_string = False
|
|
119
|
+
escape = False
|
|
120
|
+
|
|
121
|
+
for idx in range(start + 1, len(text)):
|
|
122
|
+
char = text[idx]
|
|
123
|
+
if in_string:
|
|
124
|
+
if escape:
|
|
125
|
+
escape = False
|
|
126
|
+
elif char == "\\":
|
|
127
|
+
escape = True
|
|
128
|
+
elif char == '"':
|
|
129
|
+
in_string = False
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
if char == '"':
|
|
133
|
+
in_string = True
|
|
134
|
+
elif char in "{[":
|
|
135
|
+
stack.append("}" if char == "{" else "]")
|
|
136
|
+
elif char in "}]":
|
|
137
|
+
if not stack or char != stack[-1]:
|
|
138
|
+
return None
|
|
139
|
+
stack.pop()
|
|
140
|
+
if not stack:
|
|
141
|
+
return text[start : idx + 1]
|
|
142
|
+
|
|
143
|
+
return None
|
structured_llm/schema.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, TypeAdapter
|
|
9
|
+
|
|
10
|
+
from .errors import UnsupportedSchemaError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class SchemaSpec:
|
|
15
|
+
name: str
|
|
16
|
+
adapter: TypeAdapter[Any]
|
|
17
|
+
json_schema: dict[str, Any]
|
|
18
|
+
native_json_schema: dict[str, Any]
|
|
19
|
+
prompt_schema: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_schema_spec(schema: Any, *, name: str | None = None) -> SchemaSpec:
|
|
23
|
+
try:
|
|
24
|
+
adapter = TypeAdapter(schema)
|
|
25
|
+
json_schema = adapter.json_schema()
|
|
26
|
+
except Exception as exc: # pragma: no cover - exact pydantic errors vary
|
|
27
|
+
raise UnsupportedSchemaError(f"Unsupported schema: {schema!r}") from exc
|
|
28
|
+
|
|
29
|
+
schema_name = _schema_name(schema, name)
|
|
30
|
+
prompt_schema = render_prompt_schema(json_schema)
|
|
31
|
+
return SchemaSpec(
|
|
32
|
+
name=schema_name,
|
|
33
|
+
adapter=adapter,
|
|
34
|
+
json_schema=json_schema,
|
|
35
|
+
native_json_schema=_to_strict_json_schema(json_schema),
|
|
36
|
+
prompt_schema=prompt_schema,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_prompt_schema(json_schema: dict[str, Any]) -> str:
|
|
41
|
+
defs = json_schema.get("$defs", {})
|
|
42
|
+
return _render_type(json_schema, defs, set())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _schema_name(schema: Any, explicit: str | None) -> str:
|
|
46
|
+
if explicit:
|
|
47
|
+
raw = explicit
|
|
48
|
+
elif isinstance(schema, type):
|
|
49
|
+
raw = schema.__name__
|
|
50
|
+
else:
|
|
51
|
+
raw = getattr(schema, "__name__", "structured_output")
|
|
52
|
+
name = re.sub(r"[^a-zA-Z0-9_-]+", "_", raw).strip("_") or "structured_output"
|
|
53
|
+
return name[:64]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _render_type(schema: dict[str, Any], defs: dict[str, Any], seen: set[str]) -> str:
|
|
57
|
+
if "$ref" in schema:
|
|
58
|
+
ref_name = schema["$ref"].rsplit("/", 1)[-1]
|
|
59
|
+
if ref_name in seen:
|
|
60
|
+
return ref_name
|
|
61
|
+
target = defs.get(ref_name)
|
|
62
|
+
if not isinstance(target, dict):
|
|
63
|
+
return ref_name
|
|
64
|
+
return _render_type(target, defs, {*seen, ref_name})
|
|
65
|
+
|
|
66
|
+
if "const" in schema:
|
|
67
|
+
return repr(schema["const"]).replace("'", '"')
|
|
68
|
+
|
|
69
|
+
if "enum" in schema:
|
|
70
|
+
return " or ".join(repr(item).replace("'", '"') for item in schema["enum"])
|
|
71
|
+
|
|
72
|
+
if "anyOf" in schema:
|
|
73
|
+
return " or ".join(_render_type(item, defs, seen) for item in schema["anyOf"])
|
|
74
|
+
|
|
75
|
+
if "oneOf" in schema:
|
|
76
|
+
return " or ".join(_render_type(item, defs, seen) for item in schema["oneOf"])
|
|
77
|
+
|
|
78
|
+
schema_type = schema.get("type")
|
|
79
|
+
if isinstance(schema_type, list):
|
|
80
|
+
return " or ".join(_render_type({**schema, "type": item}, defs, seen) for item in schema_type)
|
|
81
|
+
|
|
82
|
+
if schema_type == "object" or "properties" in schema:
|
|
83
|
+
properties = schema.get("properties", {})
|
|
84
|
+
required = set(schema.get("required", []))
|
|
85
|
+
if not properties:
|
|
86
|
+
return "object"
|
|
87
|
+
lines = ["{"]
|
|
88
|
+
for key, value in properties.items():
|
|
89
|
+
metadata = []
|
|
90
|
+
if key not in required:
|
|
91
|
+
metadata.append("optional")
|
|
92
|
+
description = _field_description(value)
|
|
93
|
+
if description:
|
|
94
|
+
metadata.append(description)
|
|
95
|
+
suffix = f" ({'; '.join(metadata)})" if metadata else ""
|
|
96
|
+
rendered = _render_type(value, defs, seen)
|
|
97
|
+
lines.append(f" {key}: {rendered}{suffix},")
|
|
98
|
+
lines.append("}")
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
if schema_type == "array":
|
|
102
|
+
item_schema = schema.get("items", {})
|
|
103
|
+
return f"[{_render_type(item_schema, defs, seen)}]"
|
|
104
|
+
|
|
105
|
+
if schema_type == "string":
|
|
106
|
+
return "string"
|
|
107
|
+
if schema_type == "integer":
|
|
108
|
+
return "int"
|
|
109
|
+
if schema_type == "number":
|
|
110
|
+
return "float"
|
|
111
|
+
if schema_type == "boolean":
|
|
112
|
+
return "bool"
|
|
113
|
+
if schema_type == "null":
|
|
114
|
+
return "null"
|
|
115
|
+
|
|
116
|
+
return "any"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _field_description(schema: dict[str, Any]) -> str | None:
|
|
120
|
+
description = schema.get("description")
|
|
121
|
+
if not isinstance(description, str):
|
|
122
|
+
return None
|
|
123
|
+
return " ".join(description.split())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _to_strict_json_schema(schema: Any) -> Any:
|
|
127
|
+
if isinstance(schema, list):
|
|
128
|
+
return [_to_strict_json_schema(item) for item in schema]
|
|
129
|
+
if not isinstance(schema, dict):
|
|
130
|
+
return schema
|
|
131
|
+
|
|
132
|
+
strict = {key: _to_strict_json_schema(value) for key, value in schema.items()}
|
|
133
|
+
|
|
134
|
+
if "$defs" in strict and isinstance(strict["$defs"], dict):
|
|
135
|
+
strict["$defs"] = {
|
|
136
|
+
key: _to_strict_json_schema(value)
|
|
137
|
+
for key, value in strict["$defs"].items()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
properties = strict.get("properties")
|
|
141
|
+
if isinstance(properties, dict):
|
|
142
|
+
strict["additionalProperties"] = False
|
|
143
|
+
strict["required"] = list(properties.keys())
|
|
144
|
+
|
|
145
|
+
return strict
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def is_pydantic_model(schema: Any) -> bool:
|
|
149
|
+
return isinstance(schema, type) and issubclass(schema, BaseModel)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_enum(schema: Any) -> bool:
|
|
153
|
+
return isinstance(schema, type) and issubclass(schema, Enum)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: structured-llm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight structured output runtime for Python LLM calls.
|
|
5
|
+
Author: structured-llm contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: openai>=1.0.0
|
|
9
|
+
Requires-Dist: pydantic>=2.0.0
|
|
10
|
+
Requires-Dist: python-dotenv>=1.2.2
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# structured-llm
|
|
14
|
+
|
|
15
|
+
一个轻量的 Python 结构化输出运行时。
|
|
16
|
+
|
|
17
|
+
它直接使用 Pydantic 类型作为 schema,不需要 `.baml` 文件、CLI、代码生成,也不需要额外的运行时编译器。默认行为是 BAML 风格的「output format prompt + 本地 JSON 提取/轻量修复 + Pydantic 校验」,因此不依赖特定供应商是否支持 `response_format`。
|
|
18
|
+
|
|
19
|
+
## 安装依赖
|
|
20
|
+
|
|
21
|
+
普通运行依赖:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv sync --no-config --default-index https://pypi.org/simple
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
开发依赖安装在 `dev` dependency group 中,包括 `pytest`、`pytest-asyncio`、`ruff`、`mypy`:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv sync --group dev --no-config --default-index https://pypi.org/simple
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
如果需要新增开发工具依赖:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv add <package> --group dev --no-config --default-index https://pypi.org/simple
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 使用示例
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from pydantic import BaseModel, Field
|
|
43
|
+
from structured_llm import StructuredClient
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Receipt(BaseModel):
|
|
47
|
+
merchant: str = Field(description="商户或店铺名称")
|
|
48
|
+
total: float = Field(description="收据最终支付总金额")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
client = StructuredClient(model="gpt-4o-mini", debug=True)
|
|
52
|
+
receipt = client.run("Extract the receipt: Coffee $4.50", Receipt)
|
|
53
|
+
|
|
54
|
+
print(receipt.merchant)
|
|
55
|
+
print(receipt.total)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
默认 OpenAI-compatible provider 会从 `OPENAI_API_KEY` 和 `OPENAI_BASE_URL` 读取配置;如果代码里显式传入 `api_key` 或 `base_url`,会优先使用显式参数。`examples/receipt_extraction.py` 会通过 `python-dotenv` 自动加载本地 `.env`。
|
|
59
|
+
|
|
60
|
+
`Field(description=...)` 会渲染到默认 output format prompt。`debug=True` 会把传给 OpenAI-compatible SDK 的 request payload 和模型解析前的原始输出打印到 stderr。默认不会发送 `response_format`;只有显式设置 `mode="native"` 或 `mode="auto"` 时才会尝试 provider-native structured output。
|
|
61
|
+
|
|
62
|
+
只解析已有的 LLM 文本输出:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
raw = """
|
|
66
|
+
```json
|
|
67
|
+
{"merchant": "Coffee Shop", "total": 4.5}
|
|
68
|
+
```
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
receipt = client.parse(raw, Receipt)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## 开发命令
|
|
75
|
+
|
|
76
|
+
运行测试:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
PYTHONDONTWRITEBYTECODE=1 uv run --no-config --default-index https://pypi.org/simple --group dev python -m pytest -p no:cacheprovider
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
运行 Ruff:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
uv run --no-config --default-index https://pypi.org/simple --group dev ruff check .
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
运行 mypy:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
uv run --no-config --default-index https://pypi.org/simple --group dev mypy structured_llm
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## 当前范围
|
|
95
|
+
|
|
96
|
+
- 支持同步调用:`StructuredClient.run(...)`
|
|
97
|
+
- 支持异步调用:`StructuredClient.arun(...)`
|
|
98
|
+
- 支持本地解析:`StructuredClient.parse(...)`
|
|
99
|
+
- 支持 Pydantic `BaseModel`、`list[...]`、`dict[...]`、`Literal`、`Enum` 等 `TypeAdapter` 可处理的类型
|
|
100
|
+
- 暂不支持流式 partial object、多 provider 内置适配、BAML DSL/codegen
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
structured_llm/__init__.py,sha256=5PF_0CFfAlyKzwBxmYrUw5_A52yXfa7sIzPEzOKzE2Y,501
|
|
2
|
+
structured_llm/client.py,sha256=4MeToTVi-GA4eeJh3gZ_BJrrb0fEvrjdbFwjX3z6lmE,15601
|
|
3
|
+
structured_llm/errors.py,sha256=5dRCGBAG9DwXAAaxszhUXfahXaiYwwECAO8bNbBicho,1399
|
|
4
|
+
structured_llm/parser.py,sha256=B5UgSGVPT9zcUupO4_UmrCekU7xFPKf0rD59Qw6Um3I,3811
|
|
5
|
+
structured_llm/schema.py,sha256=B9RpDsOWd6wLGnfu4u0BkeWSg5VaxwDrWvtxRSUnXF0,4830
|
|
6
|
+
structured_llm-0.1.0.dist-info/METADATA,sha256=1QKbDGfBsSaDY5fED1550ApRQc6R65kSiGA2z91gzkk,3157
|
|
7
|
+
structured_llm-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
structured_llm-0.1.0.dist-info/RECORD,,
|