dtxt 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.
- dtxt/__init__.py +23 -0
- dtxt/_config.py +55 -0
- dtxt/_util.py +18 -0
- dtxt/backends/__init__.py +33 -0
- dtxt/backends/anthropic.py +114 -0
- dtxt/backends/base.py +27 -0
- dtxt/backends/llamacpp.py +126 -0
- dtxt/backends/mock.py +58 -0
- dtxt/backends/openai.py +96 -0
- dtxt/d2t.py +53 -0
- dtxt/infer.py +96 -0
- dtxt/prompts.py +101 -0
- dtxt/py.typed +0 -0
- dtxt/roundtrip.py +54 -0
- dtxt/schema.py +116 -0
- dtxt/t2d.py +166 -0
- dtxt-0.1.0.dist-info/METADATA +144 -0
- dtxt-0.1.0.dist-info/RECORD +20 -0
- dtxt-0.1.0.dist-info/WHEEL +4 -0
- dtxt-0.1.0.dist-info/licenses/LICENSE +21 -0
dtxt/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""dtxt: schema-centric bidirectional conversion between text and structured data."""
|
|
2
|
+
|
|
3
|
+
from . import backends
|
|
4
|
+
from ._config import configure
|
|
5
|
+
from .d2t import render
|
|
6
|
+
from .infer import InferError, infer_schema
|
|
7
|
+
from .roundtrip import RoundtripResult, check_roundtrip
|
|
8
|
+
from .schema import Schema
|
|
9
|
+
from .t2d import ParseError, parse, parse_many
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"InferError",
|
|
13
|
+
"ParseError",
|
|
14
|
+
"RoundtripResult",
|
|
15
|
+
"Schema",
|
|
16
|
+
"backends",
|
|
17
|
+
"check_roundtrip",
|
|
18
|
+
"configure",
|
|
19
|
+
"infer_schema",
|
|
20
|
+
"parse",
|
|
21
|
+
"parse_many",
|
|
22
|
+
"render",
|
|
23
|
+
]
|
dtxt/_config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Global backend configuration, one slot per public function.
|
|
2
|
+
|
|
3
|
+
``configure()`` sets the default; each function's own ``backend=``
|
|
4
|
+
argument overrides it for that single call.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from .backends.base import Backend
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class _Config:
|
|
16
|
+
infer: Backend | None = None
|
|
17
|
+
parse: Backend | None = None
|
|
18
|
+
render: Backend | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_config = _Config()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def configure(
|
|
25
|
+
*,
|
|
26
|
+
infer: Backend | None = None,
|
|
27
|
+
parse: Backend | None = None,
|
|
28
|
+
render: Backend | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Set the default backend used by ``infer_schema`` / ``parse`` / ``render``."""
|
|
31
|
+
if infer is not None:
|
|
32
|
+
_config.infer = infer
|
|
33
|
+
if parse is not None:
|
|
34
|
+
_config.parse = parse
|
|
35
|
+
if render is not None:
|
|
36
|
+
_config.render = render
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_backend(function: str, override: Backend | None) -> Backend:
|
|
40
|
+
if override is not None:
|
|
41
|
+
return override
|
|
42
|
+
configured: Backend | None = getattr(_config, function)
|
|
43
|
+
if configured is None:
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
f"No backend configured for '{function}'. "
|
|
46
|
+
f"Call dtxt.configure({function}=...) or pass backend=... explicitly."
|
|
47
|
+
)
|
|
48
|
+
return configured
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def reset() -> None:
|
|
52
|
+
"""Clear all configured backends. Mainly useful between tests."""
|
|
53
|
+
_config.infer = None
|
|
54
|
+
_config.parse = None
|
|
55
|
+
_config.render = None
|
dtxt/_util.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Small helpers shared across the T2D / D2T / infer pipelines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def extract_json(raw: str) -> Any:
|
|
10
|
+
"""Parse a JSON value out of a model response, tolerating code fences."""
|
|
11
|
+
text = raw.strip()
|
|
12
|
+
if text.startswith("```"):
|
|
13
|
+
text = text.strip("`")
|
|
14
|
+
if "\n" in text:
|
|
15
|
+
first_line, rest = text.split("\n", 1)
|
|
16
|
+
if first_line.strip().isalpha():
|
|
17
|
+
text = rest
|
|
18
|
+
return json.loads(text)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Backend implementations for dtxt.
|
|
2
|
+
|
|
3
|
+
Only the mock backend is imported eagerly. API/local backends
|
|
4
|
+
(``Anthropic``, ``OpenAI``, ``LlamaCpp``) live in optional extras; their
|
|
5
|
+
modules -- and the underlying SDK packages -- are imported lazily on first
|
|
6
|
+
attribute access, so importing ``dtxt`` never requires them to be
|
|
7
|
+
installed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .base import Backend
|
|
16
|
+
from .mock import MockBackend
|
|
17
|
+
|
|
18
|
+
__all__ = ["Backend", "MockBackend", "Anthropic", "OpenAI", "LlamaCpp"]
|
|
19
|
+
|
|
20
|
+
_LAZY: dict[str, tuple[str, str]] = {
|
|
21
|
+
"Anthropic": (".anthropic", "Anthropic"),
|
|
22
|
+
"OpenAI": (".openai", "OpenAI"),
|
|
23
|
+
"LlamaCpp": (".llamacpp", "LlamaCpp"),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def __getattr__(name: str) -> Any:
|
|
28
|
+
try:
|
|
29
|
+
module_name, attr_name = _LAZY[name]
|
|
30
|
+
except KeyError:
|
|
31
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
|
32
|
+
module = importlib.import_module(module_name, __name__)
|
|
33
|
+
return getattr(module, attr_name)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Anthropic API backend: structured output via forced tool use.
|
|
2
|
+
|
|
3
|
+
The ``anthropic`` package is an optional extra (``dtxt[anthropic]``) and is
|
|
4
|
+
imported lazily, only when a client actually needs to be built.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .base import TOOL_CALLING
|
|
13
|
+
|
|
14
|
+
_INSTALL_HINT = "pip install dtxt[anthropic]"
|
|
15
|
+
_TOOL_NAME = "emit_result"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _import_anthropic() -> Any:
|
|
19
|
+
try:
|
|
20
|
+
import anthropic
|
|
21
|
+
except ImportError as exc:
|
|
22
|
+
raise ImportError(
|
|
23
|
+
"the 'anthropic' package is required to use dtxt.backends.Anthropic. "
|
|
24
|
+
f"Install it with: {_INSTALL_HINT}"
|
|
25
|
+
) from exc
|
|
26
|
+
return anthropic
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_request_kwargs(
|
|
30
|
+
model: str, prompt: str, schema: dict[str, Any] | None, max_tokens: int
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
kwargs: dict[str, Any] = {
|
|
33
|
+
"model": model,
|
|
34
|
+
"max_tokens": max_tokens,
|
|
35
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
36
|
+
}
|
|
37
|
+
if schema is not None:
|
|
38
|
+
kwargs["tools"] = [
|
|
39
|
+
{
|
|
40
|
+
"name": _TOOL_NAME,
|
|
41
|
+
"description": "Emit the result as structured data matching the schema.",
|
|
42
|
+
"input_schema": schema,
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
kwargs["tool_choice"] = {"type": "tool", "name": _TOOL_NAME}
|
|
46
|
+
return kwargs
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _extract_result(message: Any, schema: dict[str, Any] | None) -> str:
|
|
50
|
+
if schema is not None:
|
|
51
|
+
for block in message.content:
|
|
52
|
+
if getattr(block, "type", None) == "tool_use" and getattr(block, "name", None) == (
|
|
53
|
+
_TOOL_NAME
|
|
54
|
+
):
|
|
55
|
+
return json.dumps(block.input)
|
|
56
|
+
raise RuntimeError("Anthropic response did not include the expected tool_use block")
|
|
57
|
+
return "".join(
|
|
58
|
+
block.text for block in message.content if getattr(block, "type", None) == "text"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Anthropic:
|
|
63
|
+
"""Backend for the Anthropic Messages API.
|
|
64
|
+
|
|
65
|
+
Structured output is obtained by wrapping the JSON Schema as a single
|
|
66
|
+
tool and forcing ``tool_choice`` onto it, so ``tool_use.input`` is the
|
|
67
|
+
extracted object. This is not treated as ``constrained_decoding``: the
|
|
68
|
+
API does not guarantee full schema conformance the way grammar-based
|
|
69
|
+
decoding does, so dtxt still runs it through the retry + validation
|
|
70
|
+
loop.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
model: str,
|
|
76
|
+
*,
|
|
77
|
+
api_key: str | None = None,
|
|
78
|
+
max_tokens: int = 4096,
|
|
79
|
+
client: Any | None = None,
|
|
80
|
+
async_client: Any | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
self._model = model
|
|
83
|
+
self._api_key = api_key
|
|
84
|
+
self._max_tokens = max_tokens
|
|
85
|
+
self._client = client
|
|
86
|
+
self._async_client = async_client
|
|
87
|
+
|
|
88
|
+
def _get_client(self) -> Any:
|
|
89
|
+
if self._client is None:
|
|
90
|
+
anthropic = _import_anthropic()
|
|
91
|
+
kwargs: dict[str, Any] = {"api_key": self._api_key} if self._api_key else {}
|
|
92
|
+
self._client = anthropic.Anthropic(**kwargs)
|
|
93
|
+
return self._client
|
|
94
|
+
|
|
95
|
+
def _get_async_client(self) -> Any:
|
|
96
|
+
if self._async_client is None:
|
|
97
|
+
anthropic = _import_anthropic()
|
|
98
|
+
kwargs: dict[str, Any] = {"api_key": self._api_key} if self._api_key else {}
|
|
99
|
+
self._async_client = anthropic.AsyncAnthropic(**kwargs)
|
|
100
|
+
return self._async_client
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def capabilities(self) -> set[str]:
|
|
104
|
+
return {TOOL_CALLING}
|
|
105
|
+
|
|
106
|
+
def generate(self, prompt: str, *, schema: dict[str, Any] | None = None) -> str:
|
|
107
|
+
kwargs = _build_request_kwargs(self._model, prompt, schema, self._max_tokens)
|
|
108
|
+
message = self._get_client().messages.create(**kwargs)
|
|
109
|
+
return _extract_result(message, schema)
|
|
110
|
+
|
|
111
|
+
async def agenerate(self, prompt: str, *, schema: dict[str, Any] | None = None) -> str:
|
|
112
|
+
kwargs = _build_request_kwargs(self._model, prompt, schema, self._max_tokens)
|
|
113
|
+
message = await self._get_async_client().messages.create(**kwargs)
|
|
114
|
+
return _extract_result(message, schema)
|
dtxt/backends/base.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Backend abstraction: the interface every dtxt backend implements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
CONSTRAINED_DECODING = "constrained_decoding"
|
|
8
|
+
JSON_MODE = "json_mode"
|
|
9
|
+
TOOL_CALLING = "tool_calling"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@runtime_checkable
|
|
13
|
+
class Backend(Protocol):
|
|
14
|
+
"""Anything that can turn a prompt into text, optionally schema-guided.
|
|
15
|
+
|
|
16
|
+
``capabilities`` tells the caller which execution path to take:
|
|
17
|
+
|
|
18
|
+
- ``constrained_decoding``: the backend enforces the JSON schema at the
|
|
19
|
+
grammar level, so callers may skip syntactic validation and only run
|
|
20
|
+
semantic checks.
|
|
21
|
+
- otherwise: callers must retry and validate the output themselves.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def generate(self, prompt: str, *, schema: dict[str, Any] | None = None) -> str: ...
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def capabilities(self) -> set[str]: ...
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""llama.cpp backend: local GGUF models with GBNF-constrained decoding.
|
|
2
|
+
|
|
3
|
+
``llama-cpp-python`` is a heavy, optional extra (``dtxt[llamacpp]``) and is
|
|
4
|
+
imported lazily. Generation constrains JSON output at the grammar level
|
|
5
|
+
via ``response_format``. Schema constructs GBNF can't reliably express
|
|
6
|
+
(``format``, ``pattern``, deep nesting) are stripped from the
|
|
7
|
+
grammar-facing copy of the schema; the original schema is left untouched
|
|
8
|
+
for dtxt's normal post-hoc jsonschema validation. That is the two-stage
|
|
9
|
+
split: what the grammar guarantees during decoding, and what gets checked
|
|
10
|
+
afterwards.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
|
|
17
|
+
from .base import CONSTRAINED_DECODING
|
|
18
|
+
|
|
19
|
+
_INSTALL_HINT = "pip install dtxt[llamacpp]"
|
|
20
|
+
|
|
21
|
+
DEFAULT_MAX_GRAMMAR_DEPTH = 6
|
|
22
|
+
DEFAULT_PROMPT_TEMPLATE = "{prompt}"
|
|
23
|
+
|
|
24
|
+
_UNGRAMMARABLE_KEYWORDS = ("format", "pattern")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _import_llama_cpp() -> Any:
|
|
28
|
+
try:
|
|
29
|
+
import llama_cpp
|
|
30
|
+
except ImportError as exc:
|
|
31
|
+
raise ImportError(
|
|
32
|
+
"the 'llama-cpp-python' package is required to use dtxt.backends.LlamaCpp. "
|
|
33
|
+
f"Install it with: {_INSTALL_HINT}"
|
|
34
|
+
) from exc
|
|
35
|
+
return llama_cpp
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def grammar_safe_schema(
|
|
39
|
+
schema: dict[str, Any], *, max_depth: int = DEFAULT_MAX_GRAMMAR_DEPTH
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
"""Return a copy of ``schema`` with GBNF-unfriendly constructs removed.
|
|
42
|
+
|
|
43
|
+
Drops ``format``/``pattern`` everywhere, and replaces ``object``/
|
|
44
|
+
``array`` schemas past ``max_depth`` nested levels with an
|
|
45
|
+
unconstrained placeholder of the same type. The caller is expected to
|
|
46
|
+
still validate the original schema post-hoc.
|
|
47
|
+
"""
|
|
48
|
+
return cast(dict[str, Any], _strip(schema, depth=0, max_depth=max_depth))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _strip(node: Any, *, depth: int, max_depth: int) -> Any:
|
|
52
|
+
if not isinstance(node, dict):
|
|
53
|
+
return node
|
|
54
|
+
|
|
55
|
+
if depth > max_depth:
|
|
56
|
+
node_type = node.get("type")
|
|
57
|
+
if node_type in ("object", "array"):
|
|
58
|
+
return {"type": node_type}
|
|
59
|
+
return dict(node)
|
|
60
|
+
|
|
61
|
+
stripped = {key: value for key, value in node.items() if key not in _UNGRAMMARABLE_KEYWORDS}
|
|
62
|
+
if "properties" in stripped:
|
|
63
|
+
stripped["properties"] = {
|
|
64
|
+
name: _strip(prop, depth=depth + 1, max_depth=max_depth)
|
|
65
|
+
for name, prop in stripped["properties"].items()
|
|
66
|
+
}
|
|
67
|
+
if "items" in stripped:
|
|
68
|
+
stripped["items"] = _strip(stripped["items"], depth=depth + 1, max_depth=max_depth)
|
|
69
|
+
return stripped
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class LlamaCpp:
|
|
73
|
+
"""Backend for local GGUF models via ``llama-cpp-python``.
|
|
74
|
+
|
|
75
|
+
Small local models are prompt-sensitive, so the prompt can be wrapped
|
|
76
|
+
in a model-specific chat template by passing ``prompt_template`` (a
|
|
77
|
+
``str.format`` template with a single ``{prompt}`` placeholder).
|
|
78
|
+
|
|
79
|
+
In-process inference assumes a single stream: batches are processed
|
|
80
|
+
sequentially (no ``agenerate``), so llama.cpp's prompt cache is reused
|
|
81
|
+
across calls sharing a prefix instead of contending for one model
|
|
82
|
+
instance in parallel.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
model_path: str,
|
|
88
|
+
*,
|
|
89
|
+
n_ctx: int = 4096,
|
|
90
|
+
max_tokens: int = 1024,
|
|
91
|
+
max_grammar_depth: int = DEFAULT_MAX_GRAMMAR_DEPTH,
|
|
92
|
+
prompt_template: str = DEFAULT_PROMPT_TEMPLATE,
|
|
93
|
+
llama: Any | None = None,
|
|
94
|
+
**llama_kwargs: Any,
|
|
95
|
+
) -> None:
|
|
96
|
+
self._model_path = model_path
|
|
97
|
+
self._n_ctx = n_ctx
|
|
98
|
+
self._max_tokens = max_tokens
|
|
99
|
+
self._max_grammar_depth = max_grammar_depth
|
|
100
|
+
self._prompt_template = prompt_template
|
|
101
|
+
self._llama_kwargs = llama_kwargs
|
|
102
|
+
self._llama = llama
|
|
103
|
+
|
|
104
|
+
def _get_llama(self) -> Any:
|
|
105
|
+
if self._llama is None:
|
|
106
|
+
llama_cpp = _import_llama_cpp()
|
|
107
|
+
self._llama = llama_cpp.Llama(
|
|
108
|
+
model_path=self._model_path, n_ctx=self._n_ctx, **self._llama_kwargs
|
|
109
|
+
)
|
|
110
|
+
return self._llama
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def capabilities(self) -> set[str]:
|
|
114
|
+
return {CONSTRAINED_DECODING}
|
|
115
|
+
|
|
116
|
+
def generate(self, prompt: str, *, schema: dict[str, Any] | None = None) -> str:
|
|
117
|
+
llama = self._get_llama()
|
|
118
|
+
kwargs: dict[str, Any] = {
|
|
119
|
+
"messages": [{"role": "user", "content": self._prompt_template.format(prompt=prompt)}],
|
|
120
|
+
"max_tokens": self._max_tokens,
|
|
121
|
+
}
|
|
122
|
+
if schema is not None:
|
|
123
|
+
safe_schema = grammar_safe_schema(schema, max_depth=self._max_grammar_depth)
|
|
124
|
+
kwargs["response_format"] = {"type": "json_object", "schema": safe_schema}
|
|
125
|
+
response = llama.create_chat_completion(**kwargs)
|
|
126
|
+
return str(response["choices"][0]["message"]["content"])
|
dtxt/backends/mock.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""A deterministic, in-process backend used for unit tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_TYPE_PLACEHOLDERS: dict[str, Any] = {
|
|
10
|
+
"string": "",
|
|
11
|
+
"integer": 0,
|
|
12
|
+
"number": 0.0,
|
|
13
|
+
"boolean": False,
|
|
14
|
+
"array": [],
|
|
15
|
+
"object": {},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _placeholder_for(schema: dict[str, Any]) -> dict[str, Any]:
|
|
20
|
+
properties = schema.get("properties", {})
|
|
21
|
+
return {
|
|
22
|
+
name: _TYPE_PLACEHOLDERS.get(prop.get("type"), None) for name, prop in properties.items()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MockBackend:
|
|
27
|
+
"""No network, no randomness: responses are canned or computed locally.
|
|
28
|
+
|
|
29
|
+
Every call is recorded in ``calls`` so tests can assert on the prompts
|
|
30
|
+
a caller produced.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
responses: list[str] | None = None,
|
|
36
|
+
*,
|
|
37
|
+
generate_fn: Callable[[str, dict[str, Any] | None], str] | None = None,
|
|
38
|
+
capabilities: set[str] | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._responses: Iterator[str] | None = iter(responses) if responses is not None else None
|
|
41
|
+
self._generate_fn = generate_fn
|
|
42
|
+
self._capabilities = capabilities if capabilities is not None else set()
|
|
43
|
+
self.calls: list[tuple[str, dict[str, Any] | None]] = []
|
|
44
|
+
|
|
45
|
+
def generate(self, prompt: str, *, schema: dict[str, Any] | None = None) -> str:
|
|
46
|
+
self.calls.append((prompt, schema))
|
|
47
|
+
if self._generate_fn is not None:
|
|
48
|
+
return self._generate_fn(prompt, schema)
|
|
49
|
+
if self._responses is not None:
|
|
50
|
+
try:
|
|
51
|
+
return next(self._responses)
|
|
52
|
+
except StopIteration as exc:
|
|
53
|
+
raise RuntimeError("MockBackend ran out of canned responses") from exc
|
|
54
|
+
return json.dumps(_placeholder_for(schema)) if schema is not None else ""
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def capabilities(self) -> set[str]:
|
|
58
|
+
return self._capabilities
|
dtxt/backends/openai.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""OpenAI API backend: structured outputs via ``response_format``.
|
|
2
|
+
|
|
3
|
+
The ``openai`` package is an optional extra (``dtxt[openai]``) and is
|
|
4
|
+
imported lazily, only when a client actually needs to be built.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .base import JSON_MODE
|
|
12
|
+
|
|
13
|
+
_INSTALL_HINT = "pip install dtxt[openai]"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _import_openai() -> Any:
|
|
17
|
+
try:
|
|
18
|
+
import openai
|
|
19
|
+
except ImportError as exc:
|
|
20
|
+
raise ImportError(
|
|
21
|
+
"the 'openai' package is required to use dtxt.backends.OpenAI. "
|
|
22
|
+
f"Install it with: {_INSTALL_HINT}"
|
|
23
|
+
) from exc
|
|
24
|
+
return openai
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_request_kwargs(model: str, prompt: str, schema: dict[str, Any] | None) -> dict[str, Any]:
|
|
28
|
+
kwargs: dict[str, Any] = {
|
|
29
|
+
"model": model,
|
|
30
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
31
|
+
}
|
|
32
|
+
if schema is not None:
|
|
33
|
+
kwargs["response_format"] = {
|
|
34
|
+
"type": "json_schema",
|
|
35
|
+
"json_schema": {"name": "dtxt_result", "schema": schema},
|
|
36
|
+
}
|
|
37
|
+
return kwargs
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _extract_result(response: Any) -> str:
|
|
41
|
+
content = response.choices[0].message.content
|
|
42
|
+
if content is None:
|
|
43
|
+
raise RuntimeError("OpenAI response contained no content")
|
|
44
|
+
return str(content)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OpenAI:
|
|
48
|
+
"""Backend for the OpenAI Chat Completions API.
|
|
49
|
+
|
|
50
|
+
Uses ``response_format={"type": "json_schema", ...}`` (structured
|
|
51
|
+
outputs) when a schema is given. This is not treated as
|
|
52
|
+
``constrained_decoding``: OpenAI's guarantee is on JSON syntax, not on
|
|
53
|
+
every schema keyword dtxt supports, so dtxt still runs it through the
|
|
54
|
+
retry + validation loop.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
model: str,
|
|
60
|
+
*,
|
|
61
|
+
api_key: str | None = None,
|
|
62
|
+
client: Any | None = None,
|
|
63
|
+
async_client: Any | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self._model = model
|
|
66
|
+
self._api_key = api_key
|
|
67
|
+
self._client = client
|
|
68
|
+
self._async_client = async_client
|
|
69
|
+
|
|
70
|
+
def _get_client(self) -> Any:
|
|
71
|
+
if self._client is None:
|
|
72
|
+
openai = _import_openai()
|
|
73
|
+
kwargs: dict[str, Any] = {"api_key": self._api_key} if self._api_key else {}
|
|
74
|
+
self._client = openai.OpenAI(**kwargs)
|
|
75
|
+
return self._client
|
|
76
|
+
|
|
77
|
+
def _get_async_client(self) -> Any:
|
|
78
|
+
if self._async_client is None:
|
|
79
|
+
openai = _import_openai()
|
|
80
|
+
kwargs: dict[str, Any] = {"api_key": self._api_key} if self._api_key else {}
|
|
81
|
+
self._async_client = openai.AsyncOpenAI(**kwargs)
|
|
82
|
+
return self._async_client
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def capabilities(self) -> set[str]:
|
|
86
|
+
return {JSON_MODE}
|
|
87
|
+
|
|
88
|
+
def generate(self, prompt: str, *, schema: dict[str, Any] | None = None) -> str:
|
|
89
|
+
kwargs = _build_request_kwargs(self._model, prompt, schema)
|
|
90
|
+
response = self._get_client().chat.completions.create(**kwargs)
|
|
91
|
+
return _extract_result(response)
|
|
92
|
+
|
|
93
|
+
async def agenerate(self, prompt: str, *, schema: dict[str, Any] | None = None) -> str:
|
|
94
|
+
kwargs = _build_request_kwargs(self._model, prompt, schema)
|
|
95
|
+
response = await self._get_async_client().chat.completions.create(**kwargs)
|
|
96
|
+
return _extract_result(response)
|
dtxt/d2t.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""D2T: object -> text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ._config import resolve_backend
|
|
8
|
+
from .backends.base import Backend
|
|
9
|
+
from .prompts import build_d2t_prompt
|
|
10
|
+
from .schema import Schema
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _field_guidance(schema: Schema) -> str:
|
|
14
|
+
lines: list[str] = []
|
|
15
|
+
for name in schema.properties:
|
|
16
|
+
parts: list[str] = []
|
|
17
|
+
description = schema.field_description(name)
|
|
18
|
+
if description:
|
|
19
|
+
parts.append(description)
|
|
20
|
+
examples = schema.field_examples(name)
|
|
21
|
+
if examples:
|
|
22
|
+
parts.append(f"examples: {examples}")
|
|
23
|
+
style = schema.field_style(name)
|
|
24
|
+
if style:
|
|
25
|
+
parts.append(f"style: {style}")
|
|
26
|
+
if parts:
|
|
27
|
+
lines.append(f"- {name}: {'; '.join(parts)}")
|
|
28
|
+
return "\n".join(lines) if lines else "(none)"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def render(
|
|
32
|
+
obj: dict[str, Any],
|
|
33
|
+
schema: Schema,
|
|
34
|
+
*,
|
|
35
|
+
style: str | None = None,
|
|
36
|
+
backend: Backend | None = None,
|
|
37
|
+
) -> str:
|
|
38
|
+
"""Convert ``obj`` into text, guided by ``schema``'s ``x-dtxt-*`` metadata.
|
|
39
|
+
|
|
40
|
+
``style`` overrides the schema's own ``x-dtxt-style`` (its root-level
|
|
41
|
+
style hint) for this call; per-field style hints still apply on top of
|
|
42
|
+
either one.
|
|
43
|
+
"""
|
|
44
|
+
resolved = resolve_backend("render", backend)
|
|
45
|
+
json_schema = schema.to_json_schema()
|
|
46
|
+
effective_style = style if style is not None else schema.style
|
|
47
|
+
prompt = build_d2t_prompt(
|
|
48
|
+
obj,
|
|
49
|
+
json_schema,
|
|
50
|
+
style=effective_style or "(none)",
|
|
51
|
+
field_guidance=_field_guidance(schema),
|
|
52
|
+
)
|
|
53
|
+
return resolved.generate(prompt)
|
dtxt/infer.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""infer_schema: schema inference via sampling + merge.
|
|
2
|
+
|
|
3
|
+
Texts are sampled in small batches (kind to small context windows); each
|
|
4
|
+
batch yields a candidate schema, and a field is kept in the merged schema
|
|
5
|
+
only if it appears in at least ``min_coverage`` of the candidates.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ._config import resolve_backend
|
|
15
|
+
from ._util import extract_json
|
|
16
|
+
from .backends.base import Backend
|
|
17
|
+
from .prompts import build_infer_prompt
|
|
18
|
+
from .schema import Schema
|
|
19
|
+
|
|
20
|
+
DEFAULT_BATCH_SIZE = 5
|
|
21
|
+
DEFAULT_MIN_COVERAGE = 0.6
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InferError(Exception):
|
|
25
|
+
"""Raised when schema inference cannot produce a usable schema."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _batched(items: list[str], size: int) -> list[list[str]]:
|
|
29
|
+
return [items[i : i + size] for i in range(0, len(items), size)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _candidate_schema(batch: list[str], backend: Backend) -> dict[str, Any] | None:
|
|
33
|
+
prompt = build_infer_prompt(batch)
|
|
34
|
+
raw = backend.generate(prompt)
|
|
35
|
+
try:
|
|
36
|
+
candidate = extract_json(raw)
|
|
37
|
+
except json.JSONDecodeError:
|
|
38
|
+
return None
|
|
39
|
+
if not isinstance(candidate, dict) or not isinstance(candidate.get("properties"), dict):
|
|
40
|
+
return None
|
|
41
|
+
return candidate
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _merge_candidates(candidates: list[dict[str, Any]], min_coverage: float) -> dict[str, Any]:
|
|
45
|
+
n = len(candidates)
|
|
46
|
+
field_types: dict[str, Counter[str]] = {}
|
|
47
|
+
field_count: Counter[str] = Counter()
|
|
48
|
+
|
|
49
|
+
for candidate in candidates:
|
|
50
|
+
for name, field_schema in candidate["properties"].items():
|
|
51
|
+
field_count[name] += 1
|
|
52
|
+
field_types.setdefault(name, Counter())[field_schema.get("type", "string")] += 1
|
|
53
|
+
|
|
54
|
+
properties: dict[str, Any] = {}
|
|
55
|
+
required: list[str] = []
|
|
56
|
+
for name, count in field_count.items():
|
|
57
|
+
coverage = count / n
|
|
58
|
+
if coverage < min_coverage:
|
|
59
|
+
continue
|
|
60
|
+
most_common_type, _ = field_types[name].most_common(1)[0]
|
|
61
|
+
properties[name] = {"type": most_common_type}
|
|
62
|
+
if coverage >= 1.0:
|
|
63
|
+
required.append(name)
|
|
64
|
+
|
|
65
|
+
return {"type": "object", "properties": properties, "required": sorted(required)}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def infer_schema(
|
|
69
|
+
texts: list[str],
|
|
70
|
+
*,
|
|
71
|
+
backend: Backend | None = None,
|
|
72
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
73
|
+
min_coverage: float = DEFAULT_MIN_COVERAGE,
|
|
74
|
+
) -> Schema:
|
|
75
|
+
"""Infer a JSON Schema shared by ``texts``."""
|
|
76
|
+
if not texts:
|
|
77
|
+
raise InferError("cannot infer a schema from an empty text collection")
|
|
78
|
+
|
|
79
|
+
resolved = resolve_backend("infer", backend)
|
|
80
|
+
batches = _batched(texts, batch_size)
|
|
81
|
+
|
|
82
|
+
candidates = [
|
|
83
|
+
candidate
|
|
84
|
+
for candidate in (_candidate_schema(batch, resolved) for batch in batches)
|
|
85
|
+
if candidate is not None
|
|
86
|
+
]
|
|
87
|
+
if not candidates:
|
|
88
|
+
raise InferError("backend did not return any usable candidate schema")
|
|
89
|
+
|
|
90
|
+
merged = _merge_candidates(candidates, min_coverage)
|
|
91
|
+
if not merged["properties"]:
|
|
92
|
+
raise InferError(
|
|
93
|
+
f"no fields met the min_coverage={min_coverage} threshold across "
|
|
94
|
+
f"{len(candidates)} candidate schema(s)"
|
|
95
|
+
)
|
|
96
|
+
return Schema(merged)
|
dtxt/prompts.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Prompt templates for T2D, D2T, and schema inference.
|
|
2
|
+
|
|
3
|
+
Kept as plain module constants (rather than inline f-strings in the
|
|
4
|
+
pipeline modules) so a caller or a backend can override them -- e.g. a
|
|
5
|
+
small local model with different prompt sensitivities -- without touching
|
|
6
|
+
``t2d.py`` / ``d2t.py`` / ``infer.py``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
T2D_TEMPLATE = """\
|
|
15
|
+
You convert unstructured text into a single JSON object that strictly follows the given JSON Schema.
|
|
16
|
+
|
|
17
|
+
# JSON Schema
|
|
18
|
+
{schema}
|
|
19
|
+
|
|
20
|
+
# Text
|
|
21
|
+
{text}
|
|
22
|
+
|
|
23
|
+
Respond with ONLY the JSON object. Do not include markdown fences or commentary.
|
|
24
|
+
If a field's value cannot be determined from the text, set it to null."""
|
|
25
|
+
|
|
26
|
+
T2D_RETRY_TEMPLATE = """\
|
|
27
|
+
{base_prompt}
|
|
28
|
+
|
|
29
|
+
# Previous attempt
|
|
30
|
+
{previous_output}
|
|
31
|
+
|
|
32
|
+
# Validation errors
|
|
33
|
+
{errors}
|
|
34
|
+
|
|
35
|
+
Fix the JSON object so it satisfies the schema and passes validation.
|
|
36
|
+
Respond with ONLY the corrected JSON object."""
|
|
37
|
+
|
|
38
|
+
D2T_TEMPLATE = """\
|
|
39
|
+
You convert a JSON object into natural, fluent text that expresses every non-null field.
|
|
40
|
+
|
|
41
|
+
# JSON Schema
|
|
42
|
+
{schema}
|
|
43
|
+
|
|
44
|
+
# Overall style
|
|
45
|
+
{style}
|
|
46
|
+
|
|
47
|
+
# Field guidance
|
|
48
|
+
{field_guidance}
|
|
49
|
+
|
|
50
|
+
# Object
|
|
51
|
+
{obj}
|
|
52
|
+
|
|
53
|
+
Write the text now. Do not include the JSON, labels, or commentary -- only the resulting text."""
|
|
54
|
+
|
|
55
|
+
INFER_TEMPLATE = """\
|
|
56
|
+
You infer a JSON Schema that describes the common structure shared by the following texts.
|
|
57
|
+
|
|
58
|
+
# Texts
|
|
59
|
+
{texts}
|
|
60
|
+
|
|
61
|
+
Respond with ONLY a JSON Schema object (type "object", with "properties" and "required").
|
|
62
|
+
Do not include markdown fences or commentary."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_t2d_prompt(text: str, schema: dict[str, Any], *, template: str = T2D_TEMPLATE) -> str:
|
|
66
|
+
return template.format(schema=json.dumps(schema, ensure_ascii=False, indent=2), text=text)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_t2d_retry_prompt(
|
|
70
|
+
base_prompt: str,
|
|
71
|
+
previous_output: str,
|
|
72
|
+
errors: list[str],
|
|
73
|
+
*,
|
|
74
|
+
template: str = T2D_RETRY_TEMPLATE,
|
|
75
|
+
) -> str:
|
|
76
|
+
return template.format(
|
|
77
|
+
base_prompt=base_prompt,
|
|
78
|
+
previous_output=previous_output,
|
|
79
|
+
errors="\n".join(f"- {error}" for error in errors),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_d2t_prompt(
|
|
84
|
+
obj: dict[str, Any],
|
|
85
|
+
schema: dict[str, Any],
|
|
86
|
+
*,
|
|
87
|
+
style: str = "(none)",
|
|
88
|
+
field_guidance: str = "(none)",
|
|
89
|
+
template: str = D2T_TEMPLATE,
|
|
90
|
+
) -> str:
|
|
91
|
+
return template.format(
|
|
92
|
+
schema=json.dumps(schema, ensure_ascii=False, indent=2),
|
|
93
|
+
obj=json.dumps(obj, ensure_ascii=False, indent=2),
|
|
94
|
+
style=style,
|
|
95
|
+
field_guidance=field_guidance,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_infer_prompt(texts: list[str], *, template: str = INFER_TEMPLATE) -> str:
|
|
100
|
+
joined = "\n\n".join(f"[{i + 1}] {text}" for i, text in enumerate(texts))
|
|
101
|
+
return template.format(texts=joined)
|
dtxt/py.typed
ADDED
|
File without changes
|
dtxt/roundtrip.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""round-trip verification: ``parse(render(obj)) ≈ obj``.
|
|
2
|
+
|
|
3
|
+
Treated as a first-class feature: this is the main way to tell whether a
|
|
4
|
+
schema + backend pair actually preserves information through D2T then T2D.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .backends.base import Backend
|
|
13
|
+
from .d2t import render
|
|
14
|
+
from .schema import Schema
|
|
15
|
+
from .t2d import parse
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class RoundtripResult:
|
|
20
|
+
original: dict[str, Any]
|
|
21
|
+
rendered_text: str
|
|
22
|
+
reparsed: dict[str, Any]
|
|
23
|
+
mismatches: dict[str, tuple[Any, Any]] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def ok(self) -> bool:
|
|
27
|
+
return not self.mismatches
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check_roundtrip(
|
|
31
|
+
obj: dict[str, Any],
|
|
32
|
+
schema: Schema,
|
|
33
|
+
*,
|
|
34
|
+
render_backend: Backend | None = None,
|
|
35
|
+
parse_backend: Backend | None = None,
|
|
36
|
+
) -> RoundtripResult:
|
|
37
|
+
"""Render ``obj`` to text, parse it back, and diff against ``obj``.
|
|
38
|
+
|
|
39
|
+
Comparison is restricted to ``schema``'s declared fields, since ``parse``
|
|
40
|
+
only ever populates those.
|
|
41
|
+
"""
|
|
42
|
+
text = render(obj, schema, backend=render_backend)
|
|
43
|
+
reparsed = parse(text, schema, backend=parse_backend)
|
|
44
|
+
|
|
45
|
+
mismatches: dict[str, tuple[Any, Any]] = {}
|
|
46
|
+
for key in schema.properties:
|
|
47
|
+
original_value = obj.get(key)
|
|
48
|
+
reparsed_value = reparsed.get(key)
|
|
49
|
+
if original_value != reparsed_value:
|
|
50
|
+
mismatches[key] = (original_value, reparsed_value)
|
|
51
|
+
|
|
52
|
+
return RoundtripResult(
|
|
53
|
+
original=obj, rendered_text=text, reparsed=reparsed, mismatches=mismatches
|
|
54
|
+
)
|
dtxt/schema.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Schema: the JSON-Schema-compatible internal representation used across dtxt."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import jsonschema
|
|
9
|
+
from pydantic import BaseModel, create_model
|
|
10
|
+
|
|
11
|
+
_EXT_DESCRIPTION = "x-dtxt-description"
|
|
12
|
+
_EXT_EXAMPLES = "x-dtxt-examples"
|
|
13
|
+
_EXT_STYLE = "x-dtxt-style"
|
|
14
|
+
|
|
15
|
+
_JSON_TYPE_TO_PY: dict[str, Any] = {
|
|
16
|
+
"string": str,
|
|
17
|
+
"integer": int,
|
|
18
|
+
"number": float,
|
|
19
|
+
"boolean": bool,
|
|
20
|
+
"array": list,
|
|
21
|
+
"object": dict,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Schema:
|
|
26
|
+
"""A JSON-Schema-compatible schema.
|
|
27
|
+
|
|
28
|
+
D2T description metadata (description, examples, style hints) is carried
|
|
29
|
+
as ``x-dtxt-*`` extension keywords on field schemas so it round-trips
|
|
30
|
+
through plain JSON Schema tooling.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, json_schema: dict[str, Any]) -> None:
|
|
34
|
+
self._json_schema = copy.deepcopy(json_schema)
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str:
|
|
37
|
+
return f"Schema({self._json_schema!r})"
|
|
38
|
+
|
|
39
|
+
def __eq__(self, other: object) -> bool:
|
|
40
|
+
if not isinstance(other, Schema):
|
|
41
|
+
return NotImplemented
|
|
42
|
+
return self._json_schema == other._json_schema
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_pydantic(cls, model: type[BaseModel]) -> Schema:
|
|
46
|
+
return cls(model.model_json_schema())
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_dict(cls, json_schema: dict[str, Any]) -> Schema:
|
|
50
|
+
return cls(json_schema)
|
|
51
|
+
|
|
52
|
+
def to_json_schema(self) -> dict[str, Any]:
|
|
53
|
+
return copy.deepcopy(self._json_schema)
|
|
54
|
+
|
|
55
|
+
def to_pydantic(self, name: str = "DtxtModel") -> type[BaseModel]:
|
|
56
|
+
"""Build a best-effort Pydantic model from the top-level properties.
|
|
57
|
+
|
|
58
|
+
This covers the common flat-record case; deeply nested or
|
|
59
|
+
format-constrained schemas keep their full fidelity in
|
|
60
|
+
``to_json_schema()`` instead.
|
|
61
|
+
"""
|
|
62
|
+
fields: dict[str, Any] = {}
|
|
63
|
+
required = set(self.required)
|
|
64
|
+
for field_name, field_schema in self.properties.items():
|
|
65
|
+
py_type = _JSON_TYPE_TO_PY.get(field_schema.get("type"), Any)
|
|
66
|
+
if field_name in required:
|
|
67
|
+
fields[field_name] = (py_type, ...)
|
|
68
|
+
else:
|
|
69
|
+
fields[field_name] = (Optional[py_type], None) # noqa: UP045
|
|
70
|
+
return create_model(name, **fields)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def properties(self) -> dict[str, Any]:
|
|
74
|
+
return dict(self._json_schema.get("properties", {}))
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def required(self) -> list[str]:
|
|
78
|
+
return list(self._json_schema.get("required", []))
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def style(self) -> str | None:
|
|
82
|
+
"""Schema-wide D2T style hint (``x-dtxt-style`` on the schema root).
|
|
83
|
+
|
|
84
|
+
Distinct from ``field_style()``, which is per-field. ``render()``'s
|
|
85
|
+
``style=`` argument overrides this for a single call.
|
|
86
|
+
"""
|
|
87
|
+
result = self._json_schema.get(_EXT_STYLE)
|
|
88
|
+
return result if isinstance(result, str) else None
|
|
89
|
+
|
|
90
|
+
def field_description(self, field_name: str) -> str | None:
|
|
91
|
+
result = self.properties.get(field_name, {}).get(_EXT_DESCRIPTION)
|
|
92
|
+
return result if isinstance(result, str) else None
|
|
93
|
+
|
|
94
|
+
def field_examples(self, field_name: str) -> list[Any]:
|
|
95
|
+
result = self.properties.get(field_name, {}).get(_EXT_EXAMPLES, [])
|
|
96
|
+
return list(result) if isinstance(result, list) else []
|
|
97
|
+
|
|
98
|
+
def field_style(self, field_name: str) -> str | None:
|
|
99
|
+
result = self.properties.get(field_name, {}).get(_EXT_STYLE)
|
|
100
|
+
return result if isinstance(result, str) else None
|
|
101
|
+
|
|
102
|
+
def validate(self, obj: dict[str, Any]) -> None:
|
|
103
|
+
jsonschema.validate(instance=obj, schema=self._json_schema)
|
|
104
|
+
|
|
105
|
+
def is_valid(self, obj: dict[str, Any]) -> bool:
|
|
106
|
+
try:
|
|
107
|
+
self.validate(obj)
|
|
108
|
+
except jsonschema.ValidationError:
|
|
109
|
+
return False
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
def iter_errors(self, obj: dict[str, Any]) -> list[str]:
|
|
113
|
+
validator_cls = jsonschema.validators.validator_for(self._json_schema)
|
|
114
|
+
validator_cls.check_schema(self._json_schema)
|
|
115
|
+
validator = validator_cls(self._json_schema)
|
|
116
|
+
return [error.message for error in validator.iter_errors(obj)]
|
dtxt/t2d.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""T2D: text -> schema-conformant object."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ._config import resolve_backend
|
|
10
|
+
from ._util import extract_json
|
|
11
|
+
from .backends.base import Backend
|
|
12
|
+
from .prompts import build_t2d_prompt, build_t2d_retry_prompt
|
|
13
|
+
from .schema import Schema
|
|
14
|
+
|
|
15
|
+
DEFAULT_MAX_RETRIES = 3
|
|
16
|
+
DEFAULT_MAX_CONCURRENCY = 8
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ParseError(Exception):
|
|
20
|
+
"""Raised when a backend fails to produce a schema-conformant object."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse(
|
|
24
|
+
text: str,
|
|
25
|
+
schema: Schema,
|
|
26
|
+
*,
|
|
27
|
+
backend: Backend | None = None,
|
|
28
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
"""Convert ``text`` into an object conforming to ``schema``.
|
|
31
|
+
|
|
32
|
+
Missing or unextractable fields are represented as ``None``.
|
|
33
|
+
"""
|
|
34
|
+
resolved = resolve_backend("parse", backend)
|
|
35
|
+
json_schema = schema.to_json_schema()
|
|
36
|
+
prompt = build_t2d_prompt(text, json_schema)
|
|
37
|
+
|
|
38
|
+
# A constrained-decoding backend only guarantees the grammar-facing part
|
|
39
|
+
# of the schema (see backends/llamacpp.py's two-stage split); keywords
|
|
40
|
+
# like `format`/`pattern` still need this retry + validation loop, so
|
|
41
|
+
# `max_retries` applies uniformly regardless of backend capabilities.
|
|
42
|
+
attempts = max_retries
|
|
43
|
+
|
|
44
|
+
last_error: Exception = ParseError("backend produced no output")
|
|
45
|
+
raw = resolved.generate(prompt, schema=json_schema)
|
|
46
|
+
for attempt in range(attempts):
|
|
47
|
+
is_last_attempt = attempt == attempts - 1
|
|
48
|
+
try:
|
|
49
|
+
obj = extract_json(raw)
|
|
50
|
+
except json.JSONDecodeError as exc:
|
|
51
|
+
last_error = exc
|
|
52
|
+
if is_last_attempt:
|
|
53
|
+
break
|
|
54
|
+
raw = resolved.generate(
|
|
55
|
+
build_t2d_retry_prompt(prompt, raw, [f"invalid JSON: {exc}"]),
|
|
56
|
+
schema=json_schema,
|
|
57
|
+
)
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
errors = schema.iter_errors(obj)
|
|
61
|
+
if not errors:
|
|
62
|
+
return dict(obj)
|
|
63
|
+
last_error = ValueError("; ".join(errors))
|
|
64
|
+
if is_last_attempt:
|
|
65
|
+
break
|
|
66
|
+
raw = resolved.generate(build_t2d_retry_prompt(prompt, raw, errors), schema=json_schema)
|
|
67
|
+
|
|
68
|
+
raise ParseError(
|
|
69
|
+
f"failed to parse a schema-conformant object after {attempts} attempt(s): {last_error}"
|
|
70
|
+
) from last_error
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_many(
|
|
74
|
+
texts: list[str],
|
|
75
|
+
schema: Schema,
|
|
76
|
+
*,
|
|
77
|
+
backend: Backend | None = None,
|
|
78
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
79
|
+
max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
|
|
80
|
+
) -> list[dict[str, Any]]:
|
|
81
|
+
"""Batch version of ``parse``.
|
|
82
|
+
|
|
83
|
+
When the resolved backend exposes an ``agenerate`` coroutine (API
|
|
84
|
+
backends such as Anthropic/OpenAI), texts are parsed concurrently via
|
|
85
|
+
asyncio, bounded by ``max_concurrency`` to avoid tripping rate limits.
|
|
86
|
+
Otherwise falls back to sequential ``parse`` calls (e.g. llama.cpp,
|
|
87
|
+
which assumes a single in-process stream).
|
|
88
|
+
"""
|
|
89
|
+
resolved = resolve_backend("parse", backend)
|
|
90
|
+
if not texts:
|
|
91
|
+
return []
|
|
92
|
+
if getattr(resolved, "agenerate", None) is not None:
|
|
93
|
+
return asyncio.run(_parse_many_async(texts, schema, resolved, max_retries, max_concurrency))
|
|
94
|
+
return [parse(text, schema, backend=resolved, max_retries=max_retries) for text in texts]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _parse_many_async(
|
|
98
|
+
texts: list[str],
|
|
99
|
+
schema: Schema,
|
|
100
|
+
backend: Any,
|
|
101
|
+
max_retries: int,
|
|
102
|
+
max_concurrency: int,
|
|
103
|
+
) -> list[dict[str, Any]]:
|
|
104
|
+
semaphore = asyncio.Semaphore(max_concurrency)
|
|
105
|
+
|
|
106
|
+
async def _bounded(text: str) -> dict[str, Any]:
|
|
107
|
+
async with semaphore:
|
|
108
|
+
return await _parse_async(text, schema, backend, max_retries)
|
|
109
|
+
|
|
110
|
+
# `return_exceptions=True` lets every text finish (success or failure)
|
|
111
|
+
# instead of raising on the first failure and leaving other in-flight
|
|
112
|
+
# requests to be torn down as dangling tasks.
|
|
113
|
+
results: list[dict[str, Any] | BaseException] = await asyncio.gather(
|
|
114
|
+
*(_bounded(text) for text in texts), return_exceptions=True
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
failures = [(i, r) for i, r in enumerate(results) if isinstance(r, BaseException)]
|
|
118
|
+
if failures:
|
|
119
|
+
first_index, first_error = failures[0]
|
|
120
|
+
raise ParseError(
|
|
121
|
+
f"parse_many failed for {len(failures)}/{len(texts)} text(s); "
|
|
122
|
+
f"first failure at index {first_index}: {first_error}"
|
|
123
|
+
) from first_error
|
|
124
|
+
|
|
125
|
+
return [result for result in results if isinstance(result, dict)]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def _parse_async(
|
|
129
|
+
text: str,
|
|
130
|
+
schema: Schema,
|
|
131
|
+
backend: Any,
|
|
132
|
+
max_retries: int,
|
|
133
|
+
) -> dict[str, Any]:
|
|
134
|
+
json_schema = schema.to_json_schema()
|
|
135
|
+
prompt = build_t2d_prompt(text, json_schema)
|
|
136
|
+
attempts = max_retries
|
|
137
|
+
|
|
138
|
+
last_error: Exception = ParseError("backend produced no output")
|
|
139
|
+
raw = await backend.agenerate(prompt, schema=json_schema)
|
|
140
|
+
for attempt in range(attempts):
|
|
141
|
+
is_last_attempt = attempt == attempts - 1
|
|
142
|
+
try:
|
|
143
|
+
obj = extract_json(raw)
|
|
144
|
+
except json.JSONDecodeError as exc:
|
|
145
|
+
last_error = exc
|
|
146
|
+
if is_last_attempt:
|
|
147
|
+
break
|
|
148
|
+
raw = await backend.agenerate(
|
|
149
|
+
build_t2d_retry_prompt(prompt, raw, [f"invalid JSON: {exc}"]),
|
|
150
|
+
schema=json_schema,
|
|
151
|
+
)
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
errors = schema.iter_errors(obj)
|
|
155
|
+
if not errors:
|
|
156
|
+
return dict(obj)
|
|
157
|
+
last_error = ValueError("; ".join(errors))
|
|
158
|
+
if is_last_attempt:
|
|
159
|
+
break
|
|
160
|
+
raw = await backend.agenerate(
|
|
161
|
+
build_t2d_retry_prompt(prompt, raw, errors), schema=json_schema
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
raise ParseError(
|
|
165
|
+
f"failed to parse a schema-conformant object after {attempts} attempt(s): {last_error}"
|
|
166
|
+
) from last_error
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dtxt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Schema-centric bidirectional conversion between text and structured data
|
|
5
|
+
Author: pillyshi
|
|
6
|
+
Author-email: pillyshi <pillyshi21@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: pydantic>=2.0
|
|
18
|
+
Requires-Dist: jsonschema>=4.0
|
|
19
|
+
Requires-Dist: dtxt[openai,anthropic,llamacpp] ; extra == 'all'
|
|
20
|
+
Requires-Dist: anthropic>=0.40 ; extra == 'anthropic'
|
|
21
|
+
Requires-Dist: llama-cpp-python>=0.3 ; extra == 'llamacpp'
|
|
22
|
+
Requires-Dist: openai>=1.0 ; extra == 'openai'
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Project-URL: Homepage, https://github.com/pillyshi/dtxt
|
|
25
|
+
Project-URL: Repository, https://github.com/pillyshi/dtxt
|
|
26
|
+
Project-URL: Issues, https://github.com/pillyshi/dtxt/issues
|
|
27
|
+
Provides-Extra: all
|
|
28
|
+
Provides-Extra: anthropic
|
|
29
|
+
Provides-Extra: llamacpp
|
|
30
|
+
Provides-Extra: openai
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# dtxt
|
|
34
|
+
|
|
35
|
+
Schema-centric bidirectional conversion between text and structured data.
|
|
36
|
+
|
|
37
|
+
`dtxt` is not related to [`dtx`](https://pypi.org/project/dtx/) (an AI red-teaming tool).
|
|
38
|
+
|
|
39
|
+
## Core features
|
|
40
|
+
|
|
41
|
+
1. **Schema inference** (`infer_schema`): derive a schema from a collection of texts
|
|
42
|
+
2. **T2D** (`parse`): convert text into a schema-conformant object
|
|
43
|
+
3. **D2T** (`render`): convert an object into text
|
|
44
|
+
4. **Round-trip verification** (`check_roundtrip`): check that
|
|
45
|
+
`parse(render(obj)) ≈ obj` for a given schema and backend
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install dtxt # core only
|
|
51
|
+
pip install dtxt[anthropic] # + Anthropic backend
|
|
52
|
+
pip install dtxt[openai] # + OpenAI backend
|
|
53
|
+
pip install dtxt[llamacpp] # + local GGUF models via llama.cpp
|
|
54
|
+
pip install dtxt[all] # everything
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The core package depends only on `pydantic` and `jsonschema`. Backends are
|
|
58
|
+
optional extras, imported lazily.
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import dtxt
|
|
64
|
+
from dtxt import Schema
|
|
65
|
+
from dtxt.backends import MockBackend
|
|
66
|
+
|
|
67
|
+
schema = Schema({
|
|
68
|
+
"type": "object",
|
|
69
|
+
"properties": {
|
|
70
|
+
"name": {"type": "string", "x-dtxt-description": "the person's full name"},
|
|
71
|
+
"age": {"type": "integer"},
|
|
72
|
+
},
|
|
73
|
+
"required": ["name", "age"],
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
# Backends can be set globally per function...
|
|
77
|
+
dtxt.configure(parse=MockBackend(), render=MockBackend())
|
|
78
|
+
|
|
79
|
+
# ...or overridden per call via backend=.
|
|
80
|
+
obj = dtxt.parse("Alice is 30 years old.", schema, backend=MockBackend())
|
|
81
|
+
text = dtxt.render({"name": "Alice", "age": 30}, schema)
|
|
82
|
+
|
|
83
|
+
result = dtxt.check_roundtrip({"name": "Alice", "age": 30}, schema)
|
|
84
|
+
result.ok # True if parse(render(obj)) == obj on every schema field
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Swap `MockBackend` for a real one:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
dtxt.configure(
|
|
91
|
+
infer=dtxt.backends.Anthropic("claude-sonnet-4-6"),
|
|
92
|
+
parse=dtxt.backends.LlamaCpp("model.gguf", n_ctx=8192),
|
|
93
|
+
render=dtxt.backends.Anthropic("claude-sonnet-4-6"),
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`Anthropic` uses forced tool use to get structured output; `OpenAI` uses
|
|
98
|
+
`response_format={"type": "json_schema", ...}`; `LlamaCpp` constrains
|
|
99
|
+
decoding at the grammar level via GBNF. None of them guarantee full schema
|
|
100
|
+
conformance on their own:
|
|
101
|
+
|
|
102
|
+
- Anthropic/OpenAI guarantee valid JSON syntax, not every schema keyword.
|
|
103
|
+
- `LlamaCpp` strips constructs GBNF can't reliably express (`format`,
|
|
104
|
+
`pattern`, deeply nested objects/arrays) from the grammar-facing schema;
|
|
105
|
+
the original schema is still checked afterwards.
|
|
106
|
+
|
|
107
|
+
So all three go through dtxt's retry + validation loop the same way.
|
|
108
|
+
`parse_many` runs concurrently via asyncio for Anthropic/OpenAI, bounded by
|
|
109
|
+
`max_concurrency` (default 8) to avoid tripping rate limits; `LlamaCpp`
|
|
110
|
+
processes it sequentially in-process so its prompt cache stays warm. A
|
|
111
|
+
partial batch failure raises one `ParseError` naming how many texts failed
|
|
112
|
+
and the first failing index, rather than aborting on the first error.
|
|
113
|
+
|
|
114
|
+
Style is controllable at both the schema and call level:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
schema = Schema({
|
|
118
|
+
"type": "object",
|
|
119
|
+
"properties": {"name": {"type": "string"}},
|
|
120
|
+
"required": ["name"],
|
|
121
|
+
"x-dtxt-style": "formal, third person", # schema-wide default
|
|
122
|
+
})
|
|
123
|
+
dtxt.render(obj, schema) # uses "formal, third person"
|
|
124
|
+
dtxt.render(obj, schema, style="casual, upbeat") # overrides it for this call
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Status
|
|
128
|
+
|
|
129
|
+
Early development (`0.0.x`). M1-M5 of the milestone plan are implemented:
|
|
130
|
+
`Schema`, `parse` / `parse_many` (asyncio-parallel + bounded concurrency
|
|
131
|
+
for API backends), `render` (with schema-level and per-call style
|
|
132
|
+
control), `infer_schema` (sampling + merge, `min_coverage`),
|
|
133
|
+
`check_roundtrip`, `configure`, a mock backend for testing, and the
|
|
134
|
+
Anthropic / OpenAI / llama.cpp backends. Not yet done: publishing to PyPI
|
|
135
|
+
as `0.1.0` -- see `CLAUDE.md` for the milestone plan.
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
uv sync --dev
|
|
141
|
+
uv run pytest
|
|
142
|
+
uv run ruff check . && uv run ruff format --check .
|
|
143
|
+
uv run mypy src/
|
|
144
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
dtxt/__init__.py,sha256=e84pUVjy851AnETN4ddW6TuyyDpFaZf5d31xupGy2Eg,548
|
|
2
|
+
dtxt/_config.py,sha256=GtbtiYmhKR_2_HriLPD_UkUuHTu3_xlka_Jrr1V8dmE,1413
|
|
3
|
+
dtxt/_util.py,sha256=iELcu6nCa8foaRHjvX8F43vTx8itGkvr8nbr4LqTp1c,516
|
|
4
|
+
dtxt/backends/__init__.py,sha256=8ygdU7X_qLFr7KwX4oUC0eAJyn1rzhono0UYupmfNAU,1002
|
|
5
|
+
dtxt/backends/anthropic.py,sha256=sRopjVAledL_rNq3_eFYHt2N_oXZV4BkAi3HMYyDp9Y,3963
|
|
6
|
+
dtxt/backends/base.py,sha256=eLP4emV_7vhd0Ud_pJW4gCpgqvxKUuuEIc3BYkfdcic,857
|
|
7
|
+
dtxt/backends/llamacpp.py,sha256=afIJ0j5jMdEbpm9QUK1yIek6Hl2i93h24hWfX6PJr20,4596
|
|
8
|
+
dtxt/backends/mock.py,sha256=d8sbCqqncN9nw9laXIfGXwENu-bywmsjkYaRzHSTPd4,1909
|
|
9
|
+
dtxt/backends/openai.py,sha256=9Y4gBx09ebQ_GyAaAnUBpcOTks1iGHzDAY5cpbR3nGw,3172
|
|
10
|
+
dtxt/d2t.py,sha256=1tnB4ENQRhPKg1P6sCNWmptevU2cs1CERbe664AeitI,1579
|
|
11
|
+
dtxt/infer.py,sha256=YaGxu6X_cKohGVN84Jvgz18UWWGSj5sY1Kpj0_MbrrM,3111
|
|
12
|
+
dtxt/prompts.py,sha256=n51sElLY2qYWwCd_QGkGyLt7izpQETZN7tIYsXtlFoU,2645
|
|
13
|
+
dtxt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
dtxt/roundtrip.py,sha256=mXxsDECWg-OgmVez5YdT4reaHsHXhuFzwdL7EimP2tM,1565
|
|
15
|
+
dtxt/schema.py,sha256=2xYF3op_EJNlALPeTiegN3_ZKrm5eBBjqrOhvvs-PoY,4063
|
|
16
|
+
dtxt/t2d.py,sha256=4xA3r-aYyuIqON0FTUZq0K7dd_U0uH5x6qrmRwo60HI,5625
|
|
17
|
+
dtxt-0.1.0.dist-info/licenses/LICENSE,sha256=CsKg5iZ5CavN1hLssUVZLR3KwXDysPkEzHHAE3-uwa0,1065
|
|
18
|
+
dtxt-0.1.0.dist-info/WHEEL,sha256=WvwXFgRajeoYkfRVmDhkP4Qlqo31Mk687zIO2QQoFmw,80
|
|
19
|
+
dtxt-0.1.0.dist-info/METADATA,sha256=-ATOj-yjV_rc1tRLPdYi1wIG5vIZOBaaJeddcWLVbWU,5087
|
|
20
|
+
dtxt-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pillyshi
|
|
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.
|