structguard 0.1.2__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.
- structguard/__init__.py +34 -0
- structguard/adapters/__init__.py +35 -0
- structguard/adapters/anthropic_adapter.py +34 -0
- structguard/adapters/base.py +42 -0
- structguard/adapters/gemini_adapter.py +36 -0
- structguard/adapters/groq_adapter.py +38 -0
- structguard/adapters/ollama_adapter.py +35 -0
- structguard/adapters/openai_adapter.py +38 -0
- structguard/core.py +153 -0
- structguard/exceptions.py +62 -0
- structguard/logging_utils.py +47 -0
- structguard/repair.py +125 -0
- structguard/report.py +62 -0
- structguard/retry.py +15 -0
- structguard/schema/__init__.py +3 -0
- structguard/schema/engine.py +100 -0
- structguard/validator.py +59 -0
- structguard-0.1.2.dist-info/METADATA +126 -0
- structguard-0.1.2.dist-info/RECORD +21 -0
- structguard-0.1.2.dist-info/WHEEL +4 -0
- structguard-0.1.2.dist-info/licenses/LICENSE +21 -0
structguard/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
StructGuard — Reliable structured outputs for production LLM applications.
|
|
3
|
+
|
|
4
|
+
from structguard import generate
|
|
5
|
+
|
|
6
|
+
incident = generate(llm=client, prompt="Summarize this incident.", schema=Incident)
|
|
7
|
+
"""
|
|
8
|
+
from .core import GenerationResult, generate
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
InvalidResponseError,
|
|
11
|
+
InvalidSchemaError,
|
|
12
|
+
JsonRepairError,
|
|
13
|
+
RetryLimitExceeded,
|
|
14
|
+
SchemaValidationError,
|
|
15
|
+
StructGuardError,
|
|
16
|
+
UnsupportedProviderError,
|
|
17
|
+
)
|
|
18
|
+
from .report import ValidationReport
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"generate",
|
|
24
|
+
"GenerationResult",
|
|
25
|
+
"ValidationReport",
|
|
26
|
+
"StructGuardError",
|
|
27
|
+
"SchemaValidationError",
|
|
28
|
+
"JsonRepairError",
|
|
29
|
+
"RetryLimitExceeded",
|
|
30
|
+
"UnsupportedProviderError",
|
|
31
|
+
"InvalidResponseError",
|
|
32
|
+
"InvalidSchemaError",
|
|
33
|
+
"__version__",
|
|
34
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..exceptions import UnsupportedProviderError
|
|
6
|
+
from .anthropic_adapter import AnthropicAdapter
|
|
7
|
+
from .base import BaseAdapter, RawCompletion
|
|
8
|
+
from .gemini_adapter import GeminiAdapter
|
|
9
|
+
from .groq_adapter import GroqAdapter
|
|
10
|
+
from .ollama_adapter import OllamaAdapter
|
|
11
|
+
from .openai_adapter import OpenAIAdapter
|
|
12
|
+
|
|
13
|
+
_REGISTRY: list[type[BaseAdapter]] = [
|
|
14
|
+
OpenAIAdapter,
|
|
15
|
+
AnthropicAdapter,
|
|
16
|
+
GeminiAdapter,
|
|
17
|
+
GroqAdapter,
|
|
18
|
+
OllamaAdapter,
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_adapter(client: Any, default_model: str | None = None) -> BaseAdapter:
|
|
23
|
+
"""Inspect `client` and return the matching adapter instance."""
|
|
24
|
+
for adapter_cls in _REGISTRY:
|
|
25
|
+
if adapter_cls.matches(client):
|
|
26
|
+
return adapter_cls(client, default_model=default_model)
|
|
27
|
+
raise UnsupportedProviderError(
|
|
28
|
+
"Could not determine LLM provider from the client object. "
|
|
29
|
+
"Pass an OpenAI, Anthropic, Gemini, Groq, or Ollama client instance.",
|
|
30
|
+
context={"client_type": type(client).__name__},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = ["BaseAdapter", "RawCompletion", "resolve_adapter",
|
|
35
|
+
"OpenAIAdapter", "AnthropicAdapter", "GeminiAdapter", "GroqAdapter", "OllamaAdapter"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from .base import BaseAdapter, RawCompletion
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AnthropicAdapter(BaseAdapter):
|
|
9
|
+
provider_name = "Anthropic"
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def matches(cls, client: Any) -> bool:
|
|
13
|
+
return type(client).__module__.startswith("anthropic")
|
|
14
|
+
|
|
15
|
+
def complete(self, prompt: str, *, model: Optional[str] = None,
|
|
16
|
+
system: Optional[str] = None, **kwargs) -> RawCompletion:
|
|
17
|
+
model = model or self.default_model or "claude-sonnet-4-6"
|
|
18
|
+
response = self.client.messages.create(
|
|
19
|
+
model=model,
|
|
20
|
+
max_tokens=kwargs.pop("max_tokens", 1024),
|
|
21
|
+
system=system or "You respond with valid JSON only, matching the requested schema.",
|
|
22
|
+
messages=[{"role": "user", "content": prompt}],
|
|
23
|
+
**kwargs,
|
|
24
|
+
)
|
|
25
|
+
text = "".join(block.text for block in response.content if getattr(block, "type", None) == "text")
|
|
26
|
+
usage = getattr(response, "usage", None)
|
|
27
|
+
return RawCompletion(
|
|
28
|
+
text=text,
|
|
29
|
+
model=model,
|
|
30
|
+
provider=self.provider_name,
|
|
31
|
+
tokens_input=getattr(usage, "input_tokens", None) if usage else None,
|
|
32
|
+
tokens_output=getattr(usage, "output_tokens", None) if usage else None,
|
|
33
|
+
raw_response=response,
|
|
34
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Universal LLM Adapter Layer — base interface.
|
|
3
|
+
|
|
4
|
+
Every provider adapter implements `.complete(prompt, model=None, **kwargs)`
|
|
5
|
+
and returns a `RawCompletion`, giving the rest of the SDK one shape to work
|
|
6
|
+
with regardless of provider.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RawCompletion:
|
|
17
|
+
text: str
|
|
18
|
+
model: str
|
|
19
|
+
provider: str
|
|
20
|
+
tokens_input: Optional[int] = None
|
|
21
|
+
tokens_output: Optional[int] = None
|
|
22
|
+
raw_response: Any = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BaseAdapter(ABC):
|
|
26
|
+
provider_name: str = "base"
|
|
27
|
+
|
|
28
|
+
def __init__(self, client: Any, default_model: Optional[str] = None):
|
|
29
|
+
self.client = client
|
|
30
|
+
self.default_model = default_model
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def complete(self, prompt: str, *, model: Optional[str] = None,
|
|
34
|
+
system: Optional[str] = None, **kwargs) -> RawCompletion:
|
|
35
|
+
"""Send `prompt` to the provider and return a RawCompletion."""
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def matches(cls, client: Any) -> bool:
|
|
41
|
+
"""Return True if `client` is an instance this adapter knows how to drive."""
|
|
42
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from .base import BaseAdapter, RawCompletion
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GeminiAdapter(BaseAdapter):
|
|
9
|
+
provider_name = "Gemini"
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def matches(cls, client: Any) -> bool:
|
|
13
|
+
return type(client).__module__.startswith("google.generativeai")
|
|
14
|
+
|
|
15
|
+
def complete(self, prompt: str, *, model: Optional[str] = None,
|
|
16
|
+
system: Optional[str] = None, **kwargs) -> RawCompletion:
|
|
17
|
+
model_name = model or self.default_model or "gemini-1.5-pro"
|
|
18
|
+
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
|
19
|
+
|
|
20
|
+
# google-generativeai's GenerativeModel is created per-model, so if the
|
|
21
|
+
# caller passed the module/client directly we instantiate here.
|
|
22
|
+
model_obj = self.client if hasattr(self.client, "generate_content") else self.client.GenerativeModel(model_name)
|
|
23
|
+
response = model_obj.generate_content(
|
|
24
|
+
full_prompt,
|
|
25
|
+
generation_config={"response_mime_type": "application/json"},
|
|
26
|
+
**kwargs,
|
|
27
|
+
)
|
|
28
|
+
usage = getattr(response, "usage_metadata", None)
|
|
29
|
+
return RawCompletion(
|
|
30
|
+
text=response.text,
|
|
31
|
+
model=model_name,
|
|
32
|
+
provider=self.provider_name,
|
|
33
|
+
tokens_input=getattr(usage, "prompt_token_count", None) if usage else None,
|
|
34
|
+
tokens_output=getattr(usage, "candidates_token_count", None) if usage else None,
|
|
35
|
+
raw_response=response,
|
|
36
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from .base import BaseAdapter, RawCompletion
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GroqAdapter(BaseAdapter):
|
|
9
|
+
provider_name = "Groq"
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def matches(cls, client: Any) -> bool:
|
|
13
|
+
return type(client).__module__.startswith("groq")
|
|
14
|
+
|
|
15
|
+
def complete(self, prompt: str, *, model: Optional[str] = None,
|
|
16
|
+
system: Optional[str] = None, **kwargs) -> RawCompletion:
|
|
17
|
+
model = model or self.default_model or "llama-3.3-70b-versatile"
|
|
18
|
+
messages = []
|
|
19
|
+
if system:
|
|
20
|
+
messages.append({"role": "system", "content": system})
|
|
21
|
+
messages.append({"role": "user", "content": prompt})
|
|
22
|
+
|
|
23
|
+
response = self.client.chat.completions.create(
|
|
24
|
+
model=model,
|
|
25
|
+
messages=messages,
|
|
26
|
+
response_format={"type": "json_object"},
|
|
27
|
+
**kwargs,
|
|
28
|
+
)
|
|
29
|
+
choice = response.choices[0].message.content
|
|
30
|
+
usage = getattr(response, "usage", None)
|
|
31
|
+
return RawCompletion(
|
|
32
|
+
text=choice or "",
|
|
33
|
+
model=model,
|
|
34
|
+
provider=self.provider_name,
|
|
35
|
+
tokens_input=getattr(usage, "prompt_tokens", None) if usage else None,
|
|
36
|
+
tokens_output=getattr(usage, "completion_tokens", None) if usage else None,
|
|
37
|
+
raw_response=response,
|
|
38
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from .base import BaseAdapter, RawCompletion
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OllamaAdapter(BaseAdapter):
|
|
9
|
+
provider_name = "Ollama"
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def matches(cls, client: Any) -> bool:
|
|
13
|
+
return type(client).__module__.startswith("ollama")
|
|
14
|
+
|
|
15
|
+
def complete(self, prompt: str, *, model: Optional[str] = None,
|
|
16
|
+
system: Optional[str] = None, **kwargs) -> RawCompletion:
|
|
17
|
+
model = model or self.default_model or "llama3.1"
|
|
18
|
+
messages = []
|
|
19
|
+
if system:
|
|
20
|
+
messages.append({"role": "system", "content": system})
|
|
21
|
+
messages.append({"role": "user", "content": prompt})
|
|
22
|
+
|
|
23
|
+
response = self.client.chat(
|
|
24
|
+
model=model,
|
|
25
|
+
messages=messages,
|
|
26
|
+
format="json",
|
|
27
|
+
**kwargs,
|
|
28
|
+
)
|
|
29
|
+
text = response["message"]["content"] if isinstance(response, dict) else response.message.content
|
|
30
|
+
return RawCompletion(
|
|
31
|
+
text=text,
|
|
32
|
+
model=model,
|
|
33
|
+
provider=self.provider_name,
|
|
34
|
+
raw_response=response,
|
|
35
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from .base import BaseAdapter, RawCompletion
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpenAIAdapter(BaseAdapter):
|
|
9
|
+
provider_name = "OpenAI"
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def matches(cls, client: Any) -> bool:
|
|
13
|
+
return type(client).__module__.startswith("openai")
|
|
14
|
+
|
|
15
|
+
def complete(self, prompt: str, *, model: Optional[str] = None,
|
|
16
|
+
system: Optional[str] = None, **kwargs) -> RawCompletion:
|
|
17
|
+
model = model or self.default_model or "gpt-4o-mini"
|
|
18
|
+
messages = []
|
|
19
|
+
if system:
|
|
20
|
+
messages.append({"role": "system", "content": system})
|
|
21
|
+
messages.append({"role": "user", "content": prompt})
|
|
22
|
+
|
|
23
|
+
response = self.client.chat.completions.create(
|
|
24
|
+
model=model,
|
|
25
|
+
messages=messages,
|
|
26
|
+
response_format={"type": "json_object"},
|
|
27
|
+
**kwargs,
|
|
28
|
+
)
|
|
29
|
+
choice = response.choices[0].message.content
|
|
30
|
+
usage = getattr(response, "usage", None)
|
|
31
|
+
return RawCompletion(
|
|
32
|
+
text=choice or "",
|
|
33
|
+
model=model,
|
|
34
|
+
provider=self.provider_name,
|
|
35
|
+
tokens_input=getattr(usage, "prompt_tokens", None) if usage else None,
|
|
36
|
+
tokens_output=getattr(usage, "completion_tokens", None) if usage else None,
|
|
37
|
+
raw_response=response,
|
|
38
|
+
)
|
structguard/core.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core orchestrator: the `generate()` function.
|
|
3
|
+
|
|
4
|
+
This wires together every module in the pipeline:
|
|
5
|
+
1. Normalize the schema (schema/engine.py)
|
|
6
|
+
2. Augment the prompt with schema instructions (prompting below)
|
|
7
|
+
3. Call the LLM through the resolved adapter (adapters/)
|
|
8
|
+
4. Parse + repair the raw text into JSON (repair.py)
|
|
9
|
+
5. Validate against the schema (validator.py)
|
|
10
|
+
6. On failure: build corrective feedback and retry (retry.py), up to max_retries
|
|
11
|
+
7. Return the validated object (+ optional ValidationReport)
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from typing import Any, Optional, Union
|
|
17
|
+
|
|
18
|
+
from .adapters import resolve_adapter
|
|
19
|
+
from .exceptions import InvalidResponseError, RetryLimitExceeded, SchemaValidationError
|
|
20
|
+
from .logging_utils import (
|
|
21
|
+
configure_debug_logging,
|
|
22
|
+
log_raw_output,
|
|
23
|
+
log_repair,
|
|
24
|
+
log_retry,
|
|
25
|
+
log_success,
|
|
26
|
+
log_validation_error,
|
|
27
|
+
)
|
|
28
|
+
from .repair import repair_json
|
|
29
|
+
from .report import ValidationReport
|
|
30
|
+
from .retry import build_retry_prompt
|
|
31
|
+
from .schema.engine import normalize
|
|
32
|
+
from .validator import validate
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GenerationResult:
|
|
36
|
+
"""Wrapper returned when return_report=True: the object plus its report."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, data: Any, report: ValidationReport):
|
|
39
|
+
self.data = data
|
|
40
|
+
self.report = report
|
|
41
|
+
|
|
42
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
43
|
+
return f"GenerationResult(data={self.data!r}, report={self.report})"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_schema_prompt(user_prompt: str, json_schema: dict) -> str:
|
|
47
|
+
"""Provider-Independent Prompting: append schema guidance to the developer's prompt."""
|
|
48
|
+
schema_str = json.dumps(json_schema, indent=2)
|
|
49
|
+
return (
|
|
50
|
+
f"{user_prompt}\n\n"
|
|
51
|
+
"---\n"
|
|
52
|
+
"Respond with ONLY a single valid JSON object (no markdown fences, no commentary) "
|
|
53
|
+
"that strictly conforms to this JSON Schema:\n"
|
|
54
|
+
f"{schema_str}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def generate(
|
|
59
|
+
*,
|
|
60
|
+
llm: Any,
|
|
61
|
+
prompt: str,
|
|
62
|
+
schema: Any,
|
|
63
|
+
model: Optional[str] = None,
|
|
64
|
+
system: Optional[str] = None,
|
|
65
|
+
max_retries: int = 2,
|
|
66
|
+
debug: bool = False,
|
|
67
|
+
return_report: bool = False,
|
|
68
|
+
**provider_kwargs: Any,
|
|
69
|
+
) -> Union[Any, GenerationResult]:
|
|
70
|
+
"""
|
|
71
|
+
Generate a schema-validated object from an LLM.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
llm: an initialized OpenAI / Anthropic / Gemini / Groq / Ollama client.
|
|
75
|
+
prompt: the developer's natural-language instruction.
|
|
76
|
+
schema: a Pydantic BaseModel, dataclass, TypedDict, or JSON Schema dict.
|
|
77
|
+
model: override the provider's default model.
|
|
78
|
+
system: optional system prompt.
|
|
79
|
+
max_retries: number of corrective retries after the first attempt (default 2).
|
|
80
|
+
debug: emit full lifecycle logs via the "structguard" logger.
|
|
81
|
+
return_report: if True, return a GenerationResult(data, report) instead of just data.
|
|
82
|
+
**provider_kwargs: forwarded verbatim to the underlying provider call.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
A validated instance of `schema` (or dict, for JSON Schema), or a
|
|
86
|
+
GenerationResult wrapping it + a ValidationReport.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
UnsupportedProviderError, InvalidSchemaError, JsonRepairError,
|
|
90
|
+
SchemaValidationError, RetryLimitExceeded, InvalidResponseError
|
|
91
|
+
"""
|
|
92
|
+
configure_debug_logging(debug)
|
|
93
|
+
|
|
94
|
+
normalized_schema = normalize(schema)
|
|
95
|
+
adapter = resolve_adapter(llm, default_model=model)
|
|
96
|
+
report = ValidationReport(provider=adapter.provider_name, model=model or adapter.default_model or "")
|
|
97
|
+
|
|
98
|
+
current_prompt = _build_schema_prompt(prompt, normalized_schema.json_schema())
|
|
99
|
+
last_errors: list[dict] = []
|
|
100
|
+
last_exc: Optional[Exception] = None
|
|
101
|
+
|
|
102
|
+
attempts = max_retries + 1
|
|
103
|
+
for attempt in range(1, attempts + 1):
|
|
104
|
+
if attempt > 1:
|
|
105
|
+
log_retry(attempt - 1, max_retries)
|
|
106
|
+
report.retries += 1
|
|
107
|
+
current_prompt = build_retry_prompt(
|
|
108
|
+
_build_schema_prompt(prompt, normalized_schema.json_schema()), last_errors
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
completion = adapter.complete(current_prompt, model=model, system=system, **provider_kwargs)
|
|
112
|
+
log_raw_output(completion.text)
|
|
113
|
+
|
|
114
|
+
if not completion.text or not completion.text.strip():
|
|
115
|
+
last_exc = InvalidResponseError(
|
|
116
|
+
"LLM returned an empty response.", context={"provider": adapter.provider_name}
|
|
117
|
+
)
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
parsed, repaired = repair_json(completion.text)
|
|
122
|
+
log_repair(repaired, json.dumps(parsed) if repaired else None)
|
|
123
|
+
report.repair_applied = report.repair_applied or repaired
|
|
124
|
+
except Exception as exc: # JsonRepairError
|
|
125
|
+
last_exc = exc
|
|
126
|
+
last_errors = [{"field": "<root>", "issue": str(exc), "type": "json_repair_error"}]
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
report.mark_validation_start()
|
|
130
|
+
try:
|
|
131
|
+
validated = validate(normalized_schema, parsed, raw_output=completion.text)
|
|
132
|
+
except SchemaValidationError as exc:
|
|
133
|
+
report.mark_validation_end()
|
|
134
|
+
log_validation_error(exc.errors)
|
|
135
|
+
last_exc = exc
|
|
136
|
+
last_errors = exc.errors
|
|
137
|
+
continue
|
|
138
|
+
report.mark_validation_end()
|
|
139
|
+
|
|
140
|
+
report.tokens_input = completion.tokens_input
|
|
141
|
+
report.tokens_output = completion.tokens_output
|
|
142
|
+
report.finalize("success")
|
|
143
|
+
log_success(validated)
|
|
144
|
+
|
|
145
|
+
return GenerationResult(validated, report) if return_report else validated
|
|
146
|
+
|
|
147
|
+
report.errors = last_errors
|
|
148
|
+
report.finalize("failed")
|
|
149
|
+
raise RetryLimitExceeded(
|
|
150
|
+
f"Failed to produce a schema-valid response after {attempts} attempt(s).",
|
|
151
|
+
attempts=attempts,
|
|
152
|
+
last_error=last_exc,
|
|
153
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured exception hierarchy for StructGuard.
|
|
3
|
+
|
|
4
|
+
Every exception carries actionable debugging context instead of a bare message,
|
|
5
|
+
so application code can log/handle failures precisely.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StructGuardError(Exception):
|
|
13
|
+
"""Base class for all StructGuard exceptions."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, *, context: Optional[dict[str, Any]] = None):
|
|
16
|
+
self.message = message
|
|
17
|
+
self.context = context or {}
|
|
18
|
+
super().__init__(self._format())
|
|
19
|
+
|
|
20
|
+
def _format(self) -> str:
|
|
21
|
+
if not self.context:
|
|
22
|
+
return self.message
|
|
23
|
+
ctx = ", ".join(f"{k}={v!r}" for k, v in self.context.items())
|
|
24
|
+
return f"{self.message} ({ctx})"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UnsupportedProviderError(StructGuardError):
|
|
28
|
+
"""Raised when an LLM client cannot be mapped to a known adapter."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SchemaValidationError(StructGuardError):
|
|
32
|
+
"""Raised when the final (possibly repaired) output fails schema validation."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, message: str, *, errors: Optional[list[dict[str, Any]]] = None, raw_output: str = ""):
|
|
35
|
+
self.errors = errors or []
|
|
36
|
+
self.raw_output = raw_output
|
|
37
|
+
super().__init__(message, context={"error_count": len(self.errors)})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class JsonRepairError(StructGuardError):
|
|
41
|
+
"""Raised when the repair engine cannot turn raw text into valid JSON."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, *, raw_output: str = ""):
|
|
44
|
+
self.raw_output = raw_output
|
|
45
|
+
super().__init__(message, context={"raw_output_preview": raw_output[:120]})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RetryLimitExceeded(StructGuardError):
|
|
49
|
+
"""Raised when max_retries is exhausted without producing a valid object."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str, *, attempts: int, last_error: Optional[Exception] = None):
|
|
52
|
+
self.attempts = attempts
|
|
53
|
+
self.last_error = last_error
|
|
54
|
+
super().__init__(message, context={"attempts": attempts, "last_error": str(last_error) if last_error else None})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class InvalidResponseError(StructGuardError):
|
|
58
|
+
"""Raised when the LLM provider returns an empty or malformed raw response."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InvalidSchemaError(StructGuardError):
|
|
62
|
+
"""Raised when the schema passed to `generate()` is not a supported type."""
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Logging & Debug Mode
|
|
3
|
+
=====================
|
|
4
|
+
When `debug=True` is passed to `generate()`, every stage of the lifecycle
|
|
5
|
+
(raw output, repaired output, validation errors, retries, final object,
|
|
6
|
+
latency) is emitted through the standard `logging` module under the
|
|
7
|
+
"structguard" logger, so it plugs into whatever logging setup the host
|
|
8
|
+
application already has.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("structguard")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def configure_debug_logging(enabled: bool) -> None:
|
|
18
|
+
if not enabled:
|
|
19
|
+
return
|
|
20
|
+
if not logger.handlers:
|
|
21
|
+
handler = logging.StreamHandler()
|
|
22
|
+
handler.setFormatter(logging.Formatter("[structguard] %(levelname)s: %(message)s"))
|
|
23
|
+
logger.addHandler(handler)
|
|
24
|
+
logger.setLevel(logging.DEBUG)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def log_raw_output(raw: str) -> None:
|
|
28
|
+
logger.debug("Raw LLM output:\n%s", raw)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def log_repair(applied: bool, repaired: str | None = None) -> None:
|
|
32
|
+
if applied:
|
|
33
|
+
logger.debug("JSON repair applied. Result:\n%s", repaired)
|
|
34
|
+
else:
|
|
35
|
+
logger.debug("No JSON repair needed.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def log_validation_error(errors: list[dict]) -> None:
|
|
39
|
+
logger.debug("Validation failed with %d error(s): %s", len(errors), errors)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def log_retry(attempt: int, max_retries: int) -> None:
|
|
43
|
+
logger.debug("Retrying generation (%d/%d)...", attempt, max_retries)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def log_success(obj) -> None:
|
|
47
|
+
logger.debug("Validated object produced successfully: %r", obj)
|
structguard/repair.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Automatic JSON Repair Engine
|
|
3
|
+
=============================
|
|
4
|
+
LLMs frequently emit JSON that's *almost* valid: trailing commas, markdown
|
|
5
|
+
code fences, single quotes, unescaped newlines, unclosed braces, etc.
|
|
6
|
+
This module tries a sequence of cheap, deterministic fixes before giving up.
|
|
7
|
+
|
|
8
|
+
Design note: we deliberately avoid a heavy grammar-based JSON parser here to
|
|
9
|
+
keep the SDK dependency-free. Each `_fix_*` step is idempotent and safe to
|
|
10
|
+
chain.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .exceptions import JsonRepairError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _strip_code_fences(text: str) -> str:
|
|
22
|
+
text = text.strip()
|
|
23
|
+
fence = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.DOTALL)
|
|
24
|
+
return fence.group(1) if fence else text
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _extract_json_span(text: str) -> str:
|
|
28
|
+
"""Grab the outermost {...} or [...] span, ignoring leading/trailing prose."""
|
|
29
|
+
start_candidates = [i for i in (text.find("{"), text.find("[")) if i != -1]
|
|
30
|
+
if not start_candidates:
|
|
31
|
+
return text
|
|
32
|
+
start = min(start_candidates)
|
|
33
|
+
end_brace = text.rfind("}")
|
|
34
|
+
end_bracket = text.rfind("]")
|
|
35
|
+
end = max(end_brace, end_bracket)
|
|
36
|
+
if end == -1 or end < start:
|
|
37
|
+
return text[start:]
|
|
38
|
+
return text[start : end + 1]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _fix_trailing_commas(text: str) -> str:
|
|
42
|
+
return re.sub(r",\s*([\]}])", r"\1", text)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fix_single_quotes(text: str) -> str:
|
|
46
|
+
# Only applied as a last-resort fallback (risky if strings contain apostrophes),
|
|
47
|
+
# so it's kept separate and only used in the final repair attempt.
|
|
48
|
+
return re.sub(r"'([^']*)'", r'"\1"', text)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _fix_unescaped_newlines_in_strings(text: str) -> str:
|
|
52
|
+
# Collapse raw newlines/tabs that appear inside string literals.
|
|
53
|
+
out, in_string, escape = [], False, False
|
|
54
|
+
for ch in text:
|
|
55
|
+
if escape:
|
|
56
|
+
out.append(ch)
|
|
57
|
+
escape = False
|
|
58
|
+
continue
|
|
59
|
+
if ch == "\\":
|
|
60
|
+
out.append(ch)
|
|
61
|
+
escape = True
|
|
62
|
+
continue
|
|
63
|
+
if ch == '"':
|
|
64
|
+
in_string = not in_string
|
|
65
|
+
out.append(ch)
|
|
66
|
+
continue
|
|
67
|
+
if in_string and ch in ("\n", "\t"):
|
|
68
|
+
out.append("\\n" if ch == "\n" else "\\t")
|
|
69
|
+
continue
|
|
70
|
+
out.append(ch)
|
|
71
|
+
return "".join(out)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _fix_unclosed_braces(text: str) -> str:
|
|
75
|
+
opens = text.count("{") - text.count("}")
|
|
76
|
+
sq_opens = text.count("[") - text.count("]")
|
|
77
|
+
if opens > 0:
|
|
78
|
+
text += "}" * opens
|
|
79
|
+
if sq_opens > 0:
|
|
80
|
+
text += "]" * sq_opens
|
|
81
|
+
return text
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
REPAIR_STEPS = [
|
|
85
|
+
_strip_code_fences,
|
|
86
|
+
_extract_json_span,
|
|
87
|
+
_fix_unescaped_newlines_in_strings,
|
|
88
|
+
_fix_trailing_commas,
|
|
89
|
+
_fix_unclosed_braces,
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def repair_json(raw_output: str) -> tuple[dict | list, bool]:
|
|
94
|
+
"""
|
|
95
|
+
Attempt to parse `raw_output` as JSON, applying repair steps as needed.
|
|
96
|
+
|
|
97
|
+
Returns (parsed_object, repair_was_applied).
|
|
98
|
+
Raises JsonRepairError if no combination of fixes yields valid JSON.
|
|
99
|
+
"""
|
|
100
|
+
if not raw_output or not raw_output.strip():
|
|
101
|
+
raise JsonRepairError("Empty response from LLM; nothing to parse.", raw_output=raw_output)
|
|
102
|
+
|
|
103
|
+
# Fast path: already valid JSON.
|
|
104
|
+
try:
|
|
105
|
+
return json.loads(raw_output), False
|
|
106
|
+
except json.JSONDecodeError:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
text = raw_output
|
|
110
|
+
for step in REPAIR_STEPS:
|
|
111
|
+
text = step(text)
|
|
112
|
+
try:
|
|
113
|
+
return json.loads(text), True
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
# Last resort: single-quote conversion (riskier, tried last).
|
|
118
|
+
text = _fix_single_quotes(text)
|
|
119
|
+
try:
|
|
120
|
+
return json.loads(text), True
|
|
121
|
+
except json.JSONDecodeError as exc:
|
|
122
|
+
raise JsonRepairError(
|
|
123
|
+
f"Could not repair malformed JSON: {exc}",
|
|
124
|
+
raw_output=raw_output,
|
|
125
|
+
) from exc
|
structguard/report.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Validation Report
|
|
3
|
+
==================
|
|
4
|
+
Rich metadata attached to every generation for logging/monitoring/debugging.
|
|
5
|
+
Accessible via `result.report` when using `generate(..., return_report=True)`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ValidationReport:
|
|
16
|
+
status: str = "pending" # "success" | "failed"
|
|
17
|
+
provider: str = ""
|
|
18
|
+
model: str = ""
|
|
19
|
+
retries: int = 0
|
|
20
|
+
repair_applied: bool = False
|
|
21
|
+
validation_time_ms: float = 0.0
|
|
22
|
+
total_time_ms: float = 0.0
|
|
23
|
+
tokens_input: Optional[int] = None
|
|
24
|
+
tokens_output: Optional[int] = None
|
|
25
|
+
errors: list[dict[str, Any]] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
_start: float = field(default_factory=time.perf_counter, repr=False, compare=False)
|
|
28
|
+
_validation_start: Optional[float] = field(default=None, repr=False, compare=False)
|
|
29
|
+
|
|
30
|
+
def mark_validation_start(self) -> None:
|
|
31
|
+
self._validation_start = time.perf_counter()
|
|
32
|
+
|
|
33
|
+
def mark_validation_end(self) -> None:
|
|
34
|
+
if self._validation_start is not None:
|
|
35
|
+
self.validation_time_ms = round((time.perf_counter() - self._validation_start) * 1000, 3)
|
|
36
|
+
|
|
37
|
+
def finalize(self, status: str) -> "ValidationReport":
|
|
38
|
+
self.status = status
|
|
39
|
+
self.total_time_ms = round((time.perf_counter() - self._start) * 1000, 3)
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, Any]:
|
|
43
|
+
return {
|
|
44
|
+
"status": self.status,
|
|
45
|
+
"provider": self.provider,
|
|
46
|
+
"model": self.model,
|
|
47
|
+
"retries": self.retries,
|
|
48
|
+
"repair_applied": self.repair_applied,
|
|
49
|
+
"validation_time_ms": self.validation_time_ms,
|
|
50
|
+
"total_time_ms": self.total_time_ms,
|
|
51
|
+
"tokens_input": self.tokens_input,
|
|
52
|
+
"tokens_output": self.tokens_output,
|
|
53
|
+
"errors": self.errors,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
57
|
+
icon = "✓" if self.status == "success" else "✗"
|
|
58
|
+
return (
|
|
59
|
+
f"{icon} {self.status.capitalize()} | provider={self.provider} model={self.model} "
|
|
60
|
+
f"retries={self.retries} repair_applied={self.repair_applied} "
|
|
61
|
+
f"validation={self.validation_time_ms}ms total={self.total_time_ms}ms"
|
|
62
|
+
)
|
structguard/retry.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Intelligent Retry Engine
|
|
3
|
+
==========================
|
|
4
|
+
On validation failure, builds targeted corrective feedback (exact fields and
|
|
5
|
+
error reasons) and re-prompts the model, instead of retrying blindly.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .validator import errors_to_feedback_prompt
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_retry_prompt(original_prompt: str, errors: list[dict]) -> str:
|
|
13
|
+
"""Compose the next attempt's prompt: original instructions + corrective feedback."""
|
|
14
|
+
feedback = errors_to_feedback_prompt(errors)
|
|
15
|
+
return f"{original_prompt}\n\n---\n{feedback}"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Schema Engine
|
|
3
|
+
=============
|
|
4
|
+
Normalizes four different schema styles (Pydantic BaseModel, stdlib dataclass,
|
|
5
|
+
TypedDict, raw JSON Schema dict) into one internal representation so the rest
|
|
6
|
+
of the SDK (validator, repair, prompting) doesn't need to care which style the
|
|
7
|
+
developer used.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
import json
|
|
13
|
+
import typing
|
|
14
|
+
from typing import Any, Type, Union
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, TypeAdapter, ValidationError
|
|
17
|
+
|
|
18
|
+
from ..exceptions import InvalidSchemaError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NormalizedSchema:
|
|
22
|
+
"""
|
|
23
|
+
Wraps any supported schema type behind a single interface:
|
|
24
|
+
- .json_schema() -> dict, for prompt injection
|
|
25
|
+
- .validate(data) -> instance (or dict for JSON Schema mode)
|
|
26
|
+
- .kind -> "pydantic" | "dataclass" | "typeddict" | "json_schema"
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, schema: Any):
|
|
30
|
+
self.original = schema
|
|
31
|
+
self.kind, self._adapter, self._target = self._resolve(schema)
|
|
32
|
+
|
|
33
|
+
# ---- resolution -------------------------------------------------
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def _resolve(schema: Any):
|
|
37
|
+
# 1. Pydantic BaseModel subclass
|
|
38
|
+
if isinstance(schema, type) and issubclass(schema, BaseModel):
|
|
39
|
+
return "pydantic", TypeAdapter(schema), schema
|
|
40
|
+
|
|
41
|
+
# 2. stdlib dataclass
|
|
42
|
+
if dataclasses.is_dataclass(schema) and isinstance(schema, type):
|
|
43
|
+
return "dataclass", TypeAdapter(schema), schema
|
|
44
|
+
|
|
45
|
+
# 3. TypedDict (detected via __required_keys__/__annotations__ pattern)
|
|
46
|
+
if isinstance(schema, type) and hasattr(schema, "__annotations__") and hasattr(schema, "__total__"):
|
|
47
|
+
return "typeddict", TypeAdapter(schema), schema
|
|
48
|
+
|
|
49
|
+
# 4. Raw JSON Schema dict
|
|
50
|
+
if isinstance(schema, dict) and ("type" in schema or "properties" in schema):
|
|
51
|
+
return "json_schema", None, schema
|
|
52
|
+
|
|
53
|
+
raise InvalidSchemaError(
|
|
54
|
+
"Unsupported schema type. Use a Pydantic BaseModel, a dataclass, "
|
|
55
|
+
"a TypedDict, or a raw JSON Schema dict.",
|
|
56
|
+
context={"schema_repr": repr(schema)[:200]},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# ---- public API ---------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def json_schema(self) -> dict:
|
|
62
|
+
"""Return a JSON-Schema dict describing the expected output shape."""
|
|
63
|
+
if self.kind == "json_schema":
|
|
64
|
+
return self._target
|
|
65
|
+
return self._adapter.json_schema()
|
|
66
|
+
|
|
67
|
+
def validate(self, data: dict) -> Any:
|
|
68
|
+
"""
|
|
69
|
+
Validate `data` against the schema.
|
|
70
|
+
Returns a Pydantic/dataclass instance for those kinds, or the raw
|
|
71
|
+
dict (unchanged) for json_schema kind since there's no Python class
|
|
72
|
+
to instantiate.
|
|
73
|
+
Raises pydantic.ValidationError on failure (caller wraps it).
|
|
74
|
+
"""
|
|
75
|
+
if self.kind == "json_schema":
|
|
76
|
+
# Lightweight structural check via pydantic's generic validator
|
|
77
|
+
TypeAdapter(dict).validate_python(data)
|
|
78
|
+
self._check_json_schema(data, self._target)
|
|
79
|
+
return data
|
|
80
|
+
return self._adapter.validate_python(data)
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _check_json_schema(data: dict, schema: dict) -> None:
|
|
84
|
+
"""Minimal required-field + type check for raw JSON Schema dicts."""
|
|
85
|
+
required = schema.get("required", [])
|
|
86
|
+
missing = [f for f in required if f not in data]
|
|
87
|
+
if missing:
|
|
88
|
+
raise ValidationError.from_exception_data(
|
|
89
|
+
"JSONSchema",
|
|
90
|
+
[{"type": "missing", "loc": (f,), "input": data} for f in missing],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def field_names(self) -> list[str]:
|
|
94
|
+
if self.kind == "json_schema":
|
|
95
|
+
return list(self._target.get("properties", {}).keys())
|
|
96
|
+
return list(self.json_schema().get("properties", {}).keys())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def normalize(schema: Any) -> NormalizedSchema:
|
|
100
|
+
return NormalizedSchema(schema)
|
structguard/validator.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Intelligent Output Validator
|
|
3
|
+
==============================
|
|
4
|
+
Runs the parsed/repaired JSON object through the schema engine and turns
|
|
5
|
+
pydantic's ValidationError into a compact, LLM-readable error list that the
|
|
6
|
+
retry engine can feed back as corrective feedback.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from .exceptions import SchemaValidationError
|
|
15
|
+
from .schema.engine import NormalizedSchema
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate(schema: NormalizedSchema, data: dict, raw_output: str = "") -> Any:
|
|
19
|
+
"""
|
|
20
|
+
Validate `data` against `schema`.
|
|
21
|
+
Returns the validated instance (or dict for json_schema kind).
|
|
22
|
+
Raises SchemaValidationError with a structured `.errors` list on failure.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
return schema.validate(data)
|
|
26
|
+
except ValidationError as exc:
|
|
27
|
+
raise SchemaValidationError(
|
|
28
|
+
f"Output failed schema validation with {exc.error_count()} error(s).",
|
|
29
|
+
errors=format_errors(exc),
|
|
30
|
+
raw_output=raw_output,
|
|
31
|
+
) from exc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def format_errors(exc: ValidationError) -> list[dict[str, Any]]:
|
|
35
|
+
"""Convert pydantic errors into a compact, serializable list."""
|
|
36
|
+
formatted = []
|
|
37
|
+
for err in exc.errors():
|
|
38
|
+
formatted.append(
|
|
39
|
+
{
|
|
40
|
+
"field": ".".join(str(p) for p in err["loc"]) or "<root>",
|
|
41
|
+
"issue": err["msg"],
|
|
42
|
+
"type": err["type"],
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
return formatted
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def errors_to_feedback_prompt(errors: list[dict[str, Any]]) -> str:
|
|
49
|
+
"""
|
|
50
|
+
Render validation errors as the corrective feedback message sent back
|
|
51
|
+
to the LLM on retry (see spec section "Intelligent Retry Engine").
|
|
52
|
+
"""
|
|
53
|
+
lines = ["Your previous response failed schema validation.", "", "Errors:"]
|
|
54
|
+
for e in errors:
|
|
55
|
+
lines.append(f"- {e['field']}: {e['issue']}")
|
|
56
|
+
lines.append("")
|
|
57
|
+
lines.append("Please regenerate a complete, valid JSON object that fixes every error above. "
|
|
58
|
+
"Return ONLY the JSON object, with no commentary or markdown formatting.")
|
|
59
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: structguard
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Reliable, provider-agnostic structured outputs for production LLM applications.
|
|
5
|
+
Project-URL: Homepage, https://github.com/yourusername/structguard
|
|
6
|
+
Project-URL: Repository, https://github.com/yourusername/structguard
|
|
7
|
+
Project-URL: Issues, https://github.com/yourusername/structguard/issues
|
|
8
|
+
Author-email: Sujan Ghosh <rupubally@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: anthropic,gemini,json,llm,openai,pydantic,structured-output,validation
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: pydantic>=2.0
|
|
23
|
+
Provides-Extra: all
|
|
24
|
+
Requires-Dist: anthropic>=0.30; extra == 'all'
|
|
25
|
+
Requires-Dist: google-generativeai>=0.5; extra == 'all'
|
|
26
|
+
Requires-Dist: groq>=0.5; extra == 'all'
|
|
27
|
+
Requires-Dist: openai>=1.0; extra == 'all'
|
|
28
|
+
Provides-Extra: anthropic
|
|
29
|
+
Requires-Dist: anthropic>=0.30; extra == 'anthropic'
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: build; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: twine; extra == 'dev'
|
|
35
|
+
Provides-Extra: gemini
|
|
36
|
+
Requires-Dist: google-generativeai>=0.5; extra == 'gemini'
|
|
37
|
+
Provides-Extra: groq
|
|
38
|
+
Requires-Dist: groq>=0.5; extra == 'groq'
|
|
39
|
+
Provides-Extra: openai
|
|
40
|
+
Requires-Dist: openai>=1.0; extra == 'openai'
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# StructGuard
|
|
44
|
+
|
|
45
|
+
**Reliable, provider-agnostic structured outputs for production LLM applications.**
|
|
46
|
+
|
|
47
|
+
StructGuard makes sure every LLM response conforms to a schema you define —
|
|
48
|
+
`Pydantic`, `dataclass`, `TypedDict`, or raw JSON Schema — before it reaches
|
|
49
|
+
your application code. It handles prompting, JSON repair, validation, and
|
|
50
|
+
intelligent retries in one call, across OpenAI, Anthropic, Gemini, Groq, and
|
|
51
|
+
Ollama.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install structguard # core only
|
|
57
|
+
pip install structguard[openai] # + OpenAI SDK
|
|
58
|
+
pip install structguard[anthropic] # + Anthropic SDK
|
|
59
|
+
pip install structguard[all] # every provider SDK
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quick start
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from pydantic import BaseModel
|
|
66
|
+
from openai import OpenAI
|
|
67
|
+
from structguard import generate
|
|
68
|
+
|
|
69
|
+
class Incident(BaseModel):
|
|
70
|
+
summary: str
|
|
71
|
+
priority: str
|
|
72
|
+
confidence: float
|
|
73
|
+
|
|
74
|
+
client = OpenAI()
|
|
75
|
+
|
|
76
|
+
incident = generate(
|
|
77
|
+
llm=client,
|
|
78
|
+
prompt="Summarize this incident: Database connection pool exhausted, causing 500s.",
|
|
79
|
+
schema=Incident,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
print(incident.summary, incident.priority, incident.confidence)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## With a full report
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
result = generate(llm=client, prompt=prompt, schema=Incident, return_report=True)
|
|
89
|
+
print(result.data)
|
|
90
|
+
print(result.report) # ✓ Success | provider=OpenAI model=gpt-4o-mini retries=0 ...
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Error handling
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from structguard import RetryLimitExceeded, SchemaValidationError
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
incident = generate(llm=client, prompt=prompt, schema=Incident, max_retries=2)
|
|
100
|
+
except RetryLimitExceeded as e:
|
|
101
|
+
print(f"Gave up after {e.attempts} attempts: {e.last_error}")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Supported schema types
|
|
105
|
+
|
|
106
|
+
- `pydantic.BaseModel`
|
|
107
|
+
- stdlib `@dataclass`
|
|
108
|
+
- `TypedDict`
|
|
109
|
+
- raw JSON Schema `dict`
|
|
110
|
+
|
|
111
|
+
## Supported providers
|
|
112
|
+
|
|
113
|
+
OpenAI · Anthropic · Google Gemini · Groq · Ollama
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
git clone https://github.com/sujanrupu/structguard
|
|
119
|
+
cd structguard
|
|
120
|
+
pip install -e ".[dev]"
|
|
121
|
+
pytest
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
structguard/__init__.py,sha256=wJWPmdamBxEMRR3I3HG1ttEsNuZ8yig5VNPbe4qh1vw,805
|
|
2
|
+
structguard/core.py,sha256=fjtTuQ09GaMRm0wzKnTSARh-x8M8DqKWtRfDcVVHveU,5617
|
|
3
|
+
structguard/exceptions.py,sha256=K8AIKDGCahr3HvNenCrVroRXLfOud5u5lhyMLiLYaqw,2245
|
|
4
|
+
structguard/logging_utils.py,sha256=fIE6IQkhwdUMUq5yq-i0AJ3dhtLCVCl4PJmvuB3NeMI,1437
|
|
5
|
+
structguard/repair.py,sha256=hgarjb3BMzXFgOE7YqWOHENdcPJUfaIC4IrOpUxUDXI,3748
|
|
6
|
+
structguard/report.py,sha256=Um2LcvtkL0loqh6VADoUomBprHIGX7Hx_CSsplXCgoA,2293
|
|
7
|
+
structguard/retry.py,sha256=GIpcBWG1ZgidxUVvKrzrf4rdmkBQKUV-7Kz5Ey9I_wg,557
|
|
8
|
+
structguard/validator.py,sha256=-pp4hrXprWYkgQRVShw1ljxWsQhzrzwWccnKYvM0dUo,2076
|
|
9
|
+
structguard/adapters/__init__.py,sha256=A62NWPmHDvjxj2W5dTCFg6bPpRXA8XFSlM1yiHWjmZw,1207
|
|
10
|
+
structguard/adapters/anthropic_adapter.py,sha256=DIYBEsoJgppWifi1cbA0Wrukc4YnJkvXz0ZaT-iJHTs,1324
|
|
11
|
+
structguard/adapters/base.py,sha256=YiZOJ8q2_hcm_Wvi8ejP-rvJ0nldPIgTDKhf5nbyrRY,1242
|
|
12
|
+
structguard/adapters/gemini_adapter.py,sha256=QwKEbFFp2UZNr0l8Z10mQANoNtcFH2S9-5F9FQlDX4o,1464
|
|
13
|
+
structguard/adapters/groq_adapter.py,sha256=6KkDXSdOj6ccXLl8W7cDre-gs47jXyJrtKhzIBKkqcQ,1324
|
|
14
|
+
structguard/adapters/ollama_adapter.py,sha256=R9hUnqBO8BXOD_SLfdLyDrCRCPScLaS4RIB44GgSxwM,1095
|
|
15
|
+
structguard/adapters/openai_adapter.py,sha256=0dfWUxfc3iWNHtpO9m6ZJI0QpDhrkMHABEEyBusM9r8,1318
|
|
16
|
+
structguard/schema/__init__.py,sha256=Gp2dslBmGEvvMPZ3oLX5fYwvnv-I7U2ANWrB7aPdRCw,93
|
|
17
|
+
structguard/schema/engine.py,sha256=MP74RlrolypJwjusf2urQ6fgyLPkQcC7nN2Cy8LMWkE,3809
|
|
18
|
+
structguard-0.1.2.dist-info/METADATA,sha256=LigNWqgXArflmPrn3X_lgUPVG7bOvRaOGtgBIiK8z6U,3694
|
|
19
|
+
structguard-0.1.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
20
|
+
structguard-0.1.2.dist-info/licenses/LICENSE,sha256=kmBbnNtygbUFZj4Om0JztNUr5QbPNgDERhWR6qitAnE,1068
|
|
21
|
+
structguard-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sujan Ghosh
|
|
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.
|