xstructured 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.
- xstructured/__init__.py +40 -0
- xstructured/core/__init__.py +25 -0
- xstructured/core/config.py +58 -0
- xstructured/core/errors.py +51 -0
- xstructured/core/result.py +32 -0
- xstructured/envelope/__init__.py +6 -0
- xstructured/envelope/scanner.py +174 -0
- xstructured/envelope/spec.py +25 -0
- xstructured/langchain/__init__.py +10 -0
- xstructured/langchain/_support.py +19 -0
- xstructured/langchain/repair.py +129 -0
- xstructured/langchain/result.py +41 -0
- xstructured/langchain/runnable.py +416 -0
- xstructured/parser/__init__.py +6 -0
- xstructured/parser/json_safety.py +59 -0
- xstructured/parser/parser.py +198 -0
- xstructured/parser/recovery.py +34 -0
- xstructured/py.typed +1 -0
- xstructured/schema/__init__.py +19 -0
- xstructured/schema/fingerprint.py +64 -0
- xstructured/schema/instructions.py +77 -0
- xstructured/schema/introspection.py +55 -0
- xstructured/schema/named.py +77 -0
- xstructured/streaming/__init__.py +6 -0
- xstructured/streaming/decoder.py +207 -0
- xstructured/streaming/events.py +30 -0
- xstructured-0.1.0.dist-info/METADATA +217 -0
- xstructured-0.1.0.dist-info/RECORD +30 -0
- xstructured-0.1.0.dist-info/WHEEL +4 -0
- xstructured-0.1.0.dist-info/licenses/LICENSE +202 -0
xstructured/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Schema-guided structured output parsing."""
|
|
2
|
+
|
|
3
|
+
from .core import ParserConfig, ParseResult, RecoveryConfig, RepairConfig, RepairError
|
|
4
|
+
from .envelope import EnvelopeScanner, EnvelopeSpec
|
|
5
|
+
from .langchain import XStructuredResult, XStructuredRunnable, with_xstructured_output
|
|
6
|
+
from .parser import StructuredParser
|
|
7
|
+
from .schema import (
|
|
8
|
+
NamedSchemas,
|
|
9
|
+
NamedSchemaSpec,
|
|
10
|
+
SchemaInfo,
|
|
11
|
+
fingerprint_schema,
|
|
12
|
+
inspect_named_schemas,
|
|
13
|
+
inspect_schema,
|
|
14
|
+
schema_instructions,
|
|
15
|
+
)
|
|
16
|
+
from .streaming import StreamDecoder, StreamEvent, StreamEventKind
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"EnvelopeScanner",
|
|
20
|
+
"EnvelopeSpec",
|
|
21
|
+
"NamedSchemaSpec",
|
|
22
|
+
"NamedSchemas",
|
|
23
|
+
"ParseResult",
|
|
24
|
+
"ParserConfig",
|
|
25
|
+
"RecoveryConfig",
|
|
26
|
+
"RepairConfig",
|
|
27
|
+
"RepairError",
|
|
28
|
+
"SchemaInfo",
|
|
29
|
+
"StreamDecoder",
|
|
30
|
+
"StreamEvent",
|
|
31
|
+
"StreamEventKind",
|
|
32
|
+
"StructuredParser",
|
|
33
|
+
"XStructuredResult",
|
|
34
|
+
"XStructuredRunnable",
|
|
35
|
+
"fingerprint_schema",
|
|
36
|
+
"inspect_named_schemas",
|
|
37
|
+
"inspect_schema",
|
|
38
|
+
"schema_instructions",
|
|
39
|
+
"with_xstructured_output",
|
|
40
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Core configuration, errors, and result types."""
|
|
2
|
+
|
|
3
|
+
from .config import ParserConfig, RecoveryConfig, RepairConfig
|
|
4
|
+
from .errors import (
|
|
5
|
+
EnvelopeError,
|
|
6
|
+
ParseError,
|
|
7
|
+
RecoveryError,
|
|
8
|
+
RepairError,
|
|
9
|
+
SchemaError,
|
|
10
|
+
XStructuredError,
|
|
11
|
+
)
|
|
12
|
+
from .result import ParseResult
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"EnvelopeError",
|
|
16
|
+
"ParseError",
|
|
17
|
+
"ParseResult",
|
|
18
|
+
"ParserConfig",
|
|
19
|
+
"RecoveryConfig",
|
|
20
|
+
"RecoveryError",
|
|
21
|
+
"RepairConfig",
|
|
22
|
+
"RepairError",
|
|
23
|
+
"SchemaError",
|
|
24
|
+
"XStructuredError",
|
|
25
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Configuration for structured parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RecoveryConfig(BaseModel):
|
|
9
|
+
"""Controls conservative normalization before retrying JSON parsing."""
|
|
10
|
+
|
|
11
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
12
|
+
|
|
13
|
+
enabled: bool = True
|
|
14
|
+
strip_markdown_fences: bool = True
|
|
15
|
+
strip_surrounding_text: bool = True
|
|
16
|
+
max_candidates: int = Field(default=8, ge=1, le=100)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RepairConfig(BaseModel):
|
|
20
|
+
"""Controls the optional, bounded LLM-assisted repair fallback.
|
|
21
|
+
|
|
22
|
+
Repair only runs when the caller explicitly supplies a repair
|
|
23
|
+
``Runnable`` (for example via
|
|
24
|
+
``with_xstructured_output(..., repair=repair_runnable)``); this config
|
|
25
|
+
only tunes how that opt-in fallback behaves once wired in. It is never
|
|
26
|
+
consulted when no repair ``Runnable`` is supplied, so existing
|
|
27
|
+
single-schema callers are unaffected by its defaults.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
31
|
+
|
|
32
|
+
enabled: bool = True
|
|
33
|
+
max_attempts: int = Field(default=1, ge=1, le=5)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ParserConfig(BaseModel):
|
|
37
|
+
"""Controls parser limits and recovery behavior."""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
40
|
+
|
|
41
|
+
max_input_chars: int = Field(default=1_000_000, ge=1)
|
|
42
|
+
max_envelope_chars: int = Field(default=1_000_000, ge=1)
|
|
43
|
+
max_payload_chars: int = Field(default=1_000_000, ge=1)
|
|
44
|
+
max_nesting_depth: int = Field(default=100, ge=1)
|
|
45
|
+
recovery: RecoveryConfig = Field(default_factory=RecoveryConfig)
|
|
46
|
+
require_envelope: bool = False
|
|
47
|
+
|
|
48
|
+
@field_validator(
|
|
49
|
+
"max_input_chars",
|
|
50
|
+
"max_envelope_chars",
|
|
51
|
+
"max_payload_chars",
|
|
52
|
+
"max_nesting_depth",
|
|
53
|
+
)
|
|
54
|
+
@classmethod
|
|
55
|
+
def validate_positive_limit(cls, value: int) -> int:
|
|
56
|
+
if value < 1:
|
|
57
|
+
raise ValueError("resource limits must be positive")
|
|
58
|
+
return value
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Domain-specific exceptions with parse context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class XStructuredError(Exception):
|
|
10
|
+
"""Base exception for xstructured failures."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SchemaError(XStructuredError):
|
|
14
|
+
"""Raised when a schema target cannot be introspected or validated."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EnvelopeError(XStructuredError):
|
|
18
|
+
"""Raised for invalid envelope specifications or envelope extraction."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(eq=False)
|
|
22
|
+
class ParseError(XStructuredError):
|
|
23
|
+
"""Raised when content cannot be parsed as JSON."""
|
|
24
|
+
|
|
25
|
+
message: str
|
|
26
|
+
text: str
|
|
27
|
+
cause: Exception | None = None
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
return self.message
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(eq=False)
|
|
34
|
+
class RecoveryError(ParseError):
|
|
35
|
+
"""Raised after every configured recovery candidate has failed."""
|
|
36
|
+
|
|
37
|
+
attempts: tuple[str, ...] = ()
|
|
38
|
+
validation_errors: Any | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(eq=False)
|
|
42
|
+
class RepairError(ParseError):
|
|
43
|
+
"""Raised when bounded, opt-in LLM-assisted repair exhausts its attempts.
|
|
44
|
+
|
|
45
|
+
Only raised when a repair ``Runnable`` was explicitly configured (see
|
|
46
|
+
``RepairConfig``); repair is never attempted, and this error is never
|
|
47
|
+
raised, by default.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
attempt_count: int = 0
|
|
51
|
+
repair_errors: tuple[str, ...] = ()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Result values returned by structured parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class ParseResult(Generic[T]):
|
|
13
|
+
"""A validated value and the exact parsing path used to produce it."""
|
|
14
|
+
|
|
15
|
+
value: T
|
|
16
|
+
raw: str
|
|
17
|
+
json_text: str
|
|
18
|
+
recovered: bool = False
|
|
19
|
+
envelope_found: bool = False
|
|
20
|
+
schema_name: str | None = None
|
|
21
|
+
"""The matched schema name, when parsing against named multiple schemas.
|
|
22
|
+
|
|
23
|
+
``None`` when the parser was constructed with a single schema target.
|
|
24
|
+
"""
|
|
25
|
+
repaired: bool = False
|
|
26
|
+
"""Whether this value came from the optional, bounded LLM repair fallback.
|
|
27
|
+
|
|
28
|
+
Always ``False`` for a plain `StructuredParser.parse` call; only ever
|
|
29
|
+
``True`` when `xstructured.langchain.repair.Repairer` produced the value
|
|
30
|
+
after conservative recovery was exhausted.
|
|
31
|
+
"""
|
|
32
|
+
repair_attempt_count: int = 0
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Incremental scanner for delimited structured payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
from xstructured.core.errors import EnvelopeError
|
|
9
|
+
|
|
10
|
+
from .spec import EnvelopeSpec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EnvelopeState(StrEnum):
|
|
14
|
+
SEEKING_START = "seeking_start"
|
|
15
|
+
COLLECTING = "collecting"
|
|
16
|
+
COMPLETE = "complete"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class ScanEvent:
|
|
21
|
+
"""The scanner's observable state after accepting input."""
|
|
22
|
+
|
|
23
|
+
state: EnvelopeState
|
|
24
|
+
payload: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EnvelopeScanner:
|
|
28
|
+
"""Find one envelope across arbitrary text chunks."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
spec: EnvelopeSpec | None = None,
|
|
33
|
+
*,
|
|
34
|
+
max_envelope_chars: int = 1_000_000,
|
|
35
|
+
max_payload_chars: int = 1_000_000,
|
|
36
|
+
) -> None:
|
|
37
|
+
if max_envelope_chars < 1 or max_payload_chars < 1:
|
|
38
|
+
raise ValueError("Envelope limits must be positive")
|
|
39
|
+
self.spec = spec or EnvelopeSpec()
|
|
40
|
+
self._max_envelope_chars = max_envelope_chars
|
|
41
|
+
self._max_payload_chars = max_payload_chars
|
|
42
|
+
self._state = EnvelopeState.SEEKING_START
|
|
43
|
+
self._buffer = ""
|
|
44
|
+
self._payload: str | None = None
|
|
45
|
+
self._payload_parts: list[str] = []
|
|
46
|
+
self._payload_chars = 0
|
|
47
|
+
self._in_string = False
|
|
48
|
+
self._escaped = False
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def state(self) -> EnvelopeState:
|
|
52
|
+
return self._state
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def payload(self) -> str | None:
|
|
56
|
+
return self._payload
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def complete(self) -> bool:
|
|
60
|
+
return self._state is EnvelopeState.COMPLETE
|
|
61
|
+
|
|
62
|
+
def feed(self, chunk: str) -> ScanEvent:
|
|
63
|
+
"""Accept a chunk and return the resulting scanner event."""
|
|
64
|
+
if not isinstance(chunk, str):
|
|
65
|
+
raise TypeError("Envelope scanner chunks must be strings")
|
|
66
|
+
if self.complete:
|
|
67
|
+
if chunk:
|
|
68
|
+
raise EnvelopeError("Cannot feed data after envelope completion")
|
|
69
|
+
return ScanEvent(self._state, self._payload)
|
|
70
|
+
|
|
71
|
+
self._buffer += chunk
|
|
72
|
+
if self._state is EnvelopeState.SEEKING_START:
|
|
73
|
+
start_at = self._buffer.find(self.spec.start)
|
|
74
|
+
if start_at < 0:
|
|
75
|
+
self._buffer = (
|
|
76
|
+
self._buffer[-(len(self.spec.start) - 1) :] if len(self.spec.start) > 1 else ""
|
|
77
|
+
)
|
|
78
|
+
return ScanEvent(self._state)
|
|
79
|
+
self._buffer = self._buffer[start_at + len(self.spec.start) :]
|
|
80
|
+
self._state = EnvelopeState.COLLECTING
|
|
81
|
+
|
|
82
|
+
end_at = _find_json_safe_delimiter(
|
|
83
|
+
self._buffer,
|
|
84
|
+
self.spec.end,
|
|
85
|
+
in_string=self._in_string,
|
|
86
|
+
escaped=self._escaped,
|
|
87
|
+
)
|
|
88
|
+
if end_at < 0:
|
|
89
|
+
safe_length = max(0, len(self._buffer) - len(self.spec.end) + 1)
|
|
90
|
+
self._append_payload(self._take(safe_length))
|
|
91
|
+
self._validate_incomplete_limits()
|
|
92
|
+
return ScanEvent(self._state)
|
|
93
|
+
|
|
94
|
+
self._append_payload(self._take(end_at))
|
|
95
|
+
envelope_size = len(self.spec.start) + self._payload_chars + len(self.spec.end)
|
|
96
|
+
if envelope_size > self._max_envelope_chars:
|
|
97
|
+
raise EnvelopeError(
|
|
98
|
+
f"Envelope exceeds configured limit of {self._max_envelope_chars} characters"
|
|
99
|
+
)
|
|
100
|
+
self._take(len(self.spec.end))
|
|
101
|
+
self._payload = "".join(self._payload_parts)
|
|
102
|
+
self._buffer = ""
|
|
103
|
+
self._state = EnvelopeState.COMPLETE
|
|
104
|
+
return ScanEvent(self._state, self._payload)
|
|
105
|
+
|
|
106
|
+
def finalize(self) -> str:
|
|
107
|
+
"""Return the payload, or raise when the envelope is incomplete."""
|
|
108
|
+
if self._payload is None:
|
|
109
|
+
raise EnvelopeError("No complete envelope was found")
|
|
110
|
+
return self._payload
|
|
111
|
+
|
|
112
|
+
def _validate_incomplete_limits(self) -> None:
|
|
113
|
+
envelope_size = len(self.spec.start) + self._payload_chars + len(self._buffer)
|
|
114
|
+
if envelope_size > self._max_envelope_chars:
|
|
115
|
+
raise EnvelopeError(
|
|
116
|
+
f"Envelope exceeds configured limit of {self._max_envelope_chars} characters"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def _append_payload(self, text: str) -> None:
|
|
120
|
+
if not text:
|
|
121
|
+
return
|
|
122
|
+
self._payload_parts.append(text)
|
|
123
|
+
self._payload_chars += len(text)
|
|
124
|
+
if self._payload_chars > self._max_payload_chars:
|
|
125
|
+
raise EnvelopeError(
|
|
126
|
+
f"Envelope payload exceeds configured limit of {self._max_payload_chars} characters"
|
|
127
|
+
)
|
|
128
|
+
self._in_string, self._escaped = _json_string_state(
|
|
129
|
+
text,
|
|
130
|
+
in_string=self._in_string,
|
|
131
|
+
escaped=self._escaped,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def _take(self, length: int) -> str:
|
|
135
|
+
value = self._buffer[:length]
|
|
136
|
+
self._buffer = self._buffer[length:]
|
|
137
|
+
return value
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _find_json_safe_delimiter(
|
|
141
|
+
text: str,
|
|
142
|
+
delimiter: str,
|
|
143
|
+
*,
|
|
144
|
+
in_string: bool = False,
|
|
145
|
+
escaped: bool = False,
|
|
146
|
+
) -> int:
|
|
147
|
+
"""Find a delimiter outside JSON strings, including escaped string content."""
|
|
148
|
+
for index, character in enumerate(text):
|
|
149
|
+
if in_string:
|
|
150
|
+
if escaped:
|
|
151
|
+
escaped = False
|
|
152
|
+
elif character == "\\":
|
|
153
|
+
escaped = True
|
|
154
|
+
elif character == '"':
|
|
155
|
+
in_string = False
|
|
156
|
+
elif character == '"':
|
|
157
|
+
in_string = True
|
|
158
|
+
elif text.startswith(delimiter, index):
|
|
159
|
+
return index
|
|
160
|
+
return -1
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _json_string_state(text: str, *, in_string: bool, escaped: bool) -> tuple[bool, bool]:
|
|
164
|
+
for character in text:
|
|
165
|
+
if in_string:
|
|
166
|
+
if escaped:
|
|
167
|
+
escaped = False
|
|
168
|
+
elif character == "\\":
|
|
169
|
+
escaped = True
|
|
170
|
+
elif character == '"':
|
|
171
|
+
in_string = False
|
|
172
|
+
elif character == '"':
|
|
173
|
+
in_string = True
|
|
174
|
+
return in_string, escaped
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Envelope delimiter specification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from xstructured.core.errors import EnvelopeError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class EnvelopeSpec:
|
|
12
|
+
"""Opening and closing delimiters surrounding a structured payload."""
|
|
13
|
+
|
|
14
|
+
start: str = "<xstructured>"
|
|
15
|
+
end: str = "</xstructured>"
|
|
16
|
+
|
|
17
|
+
def __post_init__(self) -> None:
|
|
18
|
+
if not self.start or not self.end:
|
|
19
|
+
raise EnvelopeError("Envelope delimiters must be non-empty")
|
|
20
|
+
if self.start == self.end:
|
|
21
|
+
raise EnvelopeError("Envelope delimiters must differ")
|
|
22
|
+
|
|
23
|
+
def wrap(self, payload: str) -> str:
|
|
24
|
+
"""Wrap *payload* with this specification's delimiters."""
|
|
25
|
+
return f"{self.start}{payload}{self.end}"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Internal helpers shared by the LangChain integration modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from langchain_core.messages import BaseMessage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def output_text(output: Any) -> str:
|
|
11
|
+
"""Extract the text content of a wrapped or repair Runnable's raw output."""
|
|
12
|
+
if isinstance(output, str):
|
|
13
|
+
return output
|
|
14
|
+
if isinstance(output, BaseMessage):
|
|
15
|
+
return output.text
|
|
16
|
+
raise TypeError(
|
|
17
|
+
"The wrapped Runnable must return strings or LangChain "
|
|
18
|
+
f"BaseMessage values, not {type(output).__name__}"
|
|
19
|
+
)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Optional, bounded LLM-assisted repair for otherwise-unparseable output.
|
|
2
|
+
|
|
3
|
+
Recovery (`xstructured.core.RecoveryConfig`) only ever changes which
|
|
4
|
+
substring of a response is treated as JSON; it never rewrites syntax (see
|
|
5
|
+
ADR 0003). Repair is a separate, strictly opt-in escape hatch: it only runs
|
|
6
|
+
when the caller supplies a *repair* `Runnable` to `with_xstructured_output`,
|
|
7
|
+
and it is bounded by `RepairConfig.max_attempts` so it can never loop
|
|
8
|
+
indefinitely. Every repaired response is re-validated by the same
|
|
9
|
+
`StructuredParser` -- the same schema(s) and the same recovery rules -- as
|
|
10
|
+
the original response, so repair can change what an *invalid* response was,
|
|
11
|
+
but never weaken what counts as *valid*.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import replace
|
|
17
|
+
from typing import Any, Generic, TypeVar
|
|
18
|
+
|
|
19
|
+
from langchain_core.runnables import Runnable, RunnableConfig
|
|
20
|
+
|
|
21
|
+
from xstructured.core import ParseError, ParseResult, RecoveryError, RepairConfig, RepairError
|
|
22
|
+
from xstructured.parser import StructuredParser
|
|
23
|
+
|
|
24
|
+
from ._support import output_text
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
_REPAIR_PREAMBLE = (
|
|
29
|
+
"The following response failed schema validation and must be corrected. "
|
|
30
|
+
"Return only a corrected response; do not add commentary or explanation."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Repairer(Generic[T]):
|
|
35
|
+
"""Bounded, opt-in retry loop that asks a Runnable to fix invalid output."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
parser: StructuredParser[T],
|
|
40
|
+
repair_runnable: Runnable[Any, Any],
|
|
41
|
+
config: RepairConfig,
|
|
42
|
+
instructions: str,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._parser = parser
|
|
45
|
+
self._repair_runnable = repair_runnable
|
|
46
|
+
self._config = config
|
|
47
|
+
self._instructions = instructions
|
|
48
|
+
|
|
49
|
+
def repair(
|
|
50
|
+
self,
|
|
51
|
+
text: str,
|
|
52
|
+
error: ParseError,
|
|
53
|
+
*,
|
|
54
|
+
config: RunnableConfig | None = None,
|
|
55
|
+
) -> ParseResult[T]:
|
|
56
|
+
"""Synchronously retry parsing, invoking the repair Runnable each attempt."""
|
|
57
|
+
if not self._config.enabled:
|
|
58
|
+
raise error
|
|
59
|
+
current_text, current_error = text, error
|
|
60
|
+
for attempt in range(1, self._config.max_attempts + 1):
|
|
61
|
+
prompt = self._build_prompt(current_text, current_error, attempt)
|
|
62
|
+
response = self._repair_runnable.invoke(prompt, config=config)
|
|
63
|
+
current_text = output_text(response)
|
|
64
|
+
try:
|
|
65
|
+
return replace(
|
|
66
|
+
self._parser.parse(current_text),
|
|
67
|
+
repaired=True,
|
|
68
|
+
repair_attempt_count=attempt,
|
|
69
|
+
)
|
|
70
|
+
except ParseError as new_error:
|
|
71
|
+
current_error = new_error
|
|
72
|
+
raise self._exhausted(current_error)
|
|
73
|
+
|
|
74
|
+
async def arepair(
|
|
75
|
+
self,
|
|
76
|
+
text: str,
|
|
77
|
+
error: ParseError,
|
|
78
|
+
*,
|
|
79
|
+
config: RunnableConfig | None = None,
|
|
80
|
+
) -> ParseResult[T]:
|
|
81
|
+
"""Asynchronously retry parsing, invoking the repair Runnable each attempt."""
|
|
82
|
+
if not self._config.enabled:
|
|
83
|
+
raise error
|
|
84
|
+
current_text, current_error = text, error
|
|
85
|
+
for attempt in range(1, self._config.max_attempts + 1):
|
|
86
|
+
prompt = self._build_prompt(current_text, current_error, attempt)
|
|
87
|
+
response = await self._repair_runnable.ainvoke(prompt, config=config)
|
|
88
|
+
current_text = output_text(response)
|
|
89
|
+
try:
|
|
90
|
+
return replace(
|
|
91
|
+
self._parser.parse(current_text),
|
|
92
|
+
repaired=True,
|
|
93
|
+
repair_attempt_count=attempt,
|
|
94
|
+
)
|
|
95
|
+
except ParseError as new_error:
|
|
96
|
+
current_error = new_error
|
|
97
|
+
raise self._exhausted(current_error)
|
|
98
|
+
|
|
99
|
+
def _build_prompt(self, text: str, error: ParseError, attempt: int) -> str:
|
|
100
|
+
messages = _error_messages(error)
|
|
101
|
+
lines = [
|
|
102
|
+
_REPAIR_PREAMBLE,
|
|
103
|
+
"",
|
|
104
|
+
self._instructions,
|
|
105
|
+
"",
|
|
106
|
+
f"Repair attempt {attempt} of {self._config.max_attempts}.",
|
|
107
|
+
"Validation errors:",
|
|
108
|
+
*(f"- {message}" for message in messages),
|
|
109
|
+
"",
|
|
110
|
+
"Invalid response:",
|
|
111
|
+
text,
|
|
112
|
+
]
|
|
113
|
+
return "\n".join(lines)
|
|
114
|
+
|
|
115
|
+
def _exhausted(self, error: ParseError) -> RepairError:
|
|
116
|
+
return RepairError(
|
|
117
|
+
"LLM-assisted repair did not produce schema-valid output within "
|
|
118
|
+
f"the configured attempt budget ({self._config.max_attempts})",
|
|
119
|
+
error.text,
|
|
120
|
+
error,
|
|
121
|
+
attempt_count=self._config.max_attempts,
|
|
122
|
+
repair_errors=_error_messages(error),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _error_messages(error: ParseError) -> tuple[str, ...]:
|
|
127
|
+
if isinstance(error, RecoveryError) and error.attempts:
|
|
128
|
+
return error.attempts
|
|
129
|
+
return (str(error),)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Result type returned by the LangChain integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any, Generic, TypeVar
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class XStructuredResult(Generic[T]):
|
|
15
|
+
"""Natural language, validated data, and the unmodified model response."""
|
|
16
|
+
|
|
17
|
+
content: str
|
|
18
|
+
structured: T
|
|
19
|
+
raw: Any
|
|
20
|
+
raw_text: str
|
|
21
|
+
json_text: str
|
|
22
|
+
recovered: bool = False
|
|
23
|
+
metadata: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({}))
|
|
24
|
+
schema_name: str | None = None
|
|
25
|
+
"""Which named schema matched, when wrapped with multiple named schemas.
|
|
26
|
+
|
|
27
|
+
``None`` when `with_xstructured_output` was called with a single schema
|
|
28
|
+
target.
|
|
29
|
+
"""
|
|
30
|
+
repaired: bool = False
|
|
31
|
+
"""Whether the optional, bounded LLM repair fallback produced this value.
|
|
32
|
+
|
|
33
|
+
Always ``False`` unless a *repair* `Runnable` was supplied to
|
|
34
|
+
`with_xstructured_output` and conservative recovery was exhausted first.
|
|
35
|
+
"""
|
|
36
|
+
repair_attempt_count: int = 0
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def text(self) -> str:
|
|
40
|
+
"""Alias for the natural-language content."""
|
|
41
|
+
return self.content
|