Skip to content

Commit 9240d34

Browse files
feat(generation): add support for custom structured output schema (#634)
* feat(generation): add support for custom structured output schema - Introduce `generation.output_schema` and `output.format` parameters to configure direct structured output based on a user-defined JSON schema. - When `output_schema` is provided, the generator now bypasses the standard conversation format and uses constrained decoding to generate output that adheres directly to the specified schema. - Implement a dynamic model creation utility (`make_dynamic_model`) to convert a raw JSON schema dictionary into a Pydantic-compatible class, enabling its use with constrained decoding. - Update the `outlines` and `transformers` dependencies to their latest versions to support these new capabilities. Signed-off-by: Luke Hinds <lukehinds@gmail.com> * build(deps): bump docker/metadata-action from 5.10.0 to 6.0.0 (#615) Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.10.0 to 6.0.0. - [Release notes](https://github.com/docker/metadata-action/releases) - [Commits](docker/metadata-action@c299e40...030e881) --- updated-dependencies: - dependency-name: docker/metadata-action dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump docker/setup-buildx-action from 3.12.0 to 4.0.0 (#616) Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.12.0 to 4.0.0. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](docker/setup-buildx-action@8d2750c...4d04d5d) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump sigstore/cosign-installer from 4.0.0 to 4.1.1 (#627) Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 4.0.0 to 4.1.1. - [Release notes](https://github.com/sigstore/cosign-installer/releases) - [Commits](sigstore/cosign-installer@faadad0...cad07c2) --- updated-dependencies: - dependency-name: sigstore/cosign-installer dependency-version: 4.1.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump docker/build-push-action from 6.19.2 to 7.1.0 (#628) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.1.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](docker/build-push-action@10e90e3...bcafcac) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: 7.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1 (#626) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.0 to 7.0.1. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](actions/upload-artifact@bbbca2d...043fb46) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump outlines from 1.2.12 to 1.3.0 (#632) Bumps [outlines](https://github.com/dottxt-ai/outlines) from 1.2.12 to 1.3.0. - [Release notes](https://github.com/dottxt-ai/outlines/releases) - [Commits](dottxt-ai/outlines@1.2.12...1.3.0) --- updated-dependencies: - dependency-name: outlines dependency-version: 1.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump transformers from 5.2.0 to 5.8.1 (#633) Bumps [transformers](https://github.com/huggingface/transformers) from 5.2.0 to 5.8.1. - [Release notes](https://github.com/huggingface/transformers/releases) - [Commits](huggingface/transformers@v5.2.0...v5.8.1) --- updated-dependencies: - dependency-name: transformers dependency-version: 5.8.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): update dependencies and bump project version - Bump project version to 4.13.0 - Update several key dependencies to their latest versions, including: - `outlines` from 1.2.12 to 1.3.0 - `transformers` from 5.2.0 to 5.8.1 - `hf-xet` from 1.2.0 to 1.5.0 - `huggingface-hub` from 1.3.1 to 1.15.0 - Replace `typer-slim` (0.21.1) with the full `typer` package (0.25.1), which introduces `annotated-doc` as a new dependency. Signed-off-by: Luke Hinds <lukehinds@gmail.com> * refactor(generator): optimize llm client import and return flow - Move `make_dynamic_model` import to the top of `generator.py` to centralize imports and avoid redundant declarations within methods. - Adjust the `_generate_with_retry` method to explicitly return the successful `result` only if no exception occurred during `llm_client.chat_completion`. This clarifies control flow by placing `return True, result` in an `else` block after the `try...except` block. - Add `noqa: PLR0911` to suppress a Pylint warning regarding too many return statements in `_generate_with_retry`, as the method's multiple return paths are essential for its retry and error handling logic. Signed-off-by: Luke Hinds <lukehinds@gmail.com> --------- Signed-off-by: Luke Hinds <lukehinds@gmail.com> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1 parent b49bccd commit 9240d34

6 files changed

Lines changed: 225 additions & 88 deletions

File tree

deepfabric/config.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,15 @@ class GenerationConfig(BaseModel):
263263
)
264264
save_as: str | None = Field(default=None, description="Where to save the generated samples")
265265

266+
# Custom structured output schema (bypasses conversation format)
267+
output_schema: dict | None = Field(
268+
default=None,
269+
description=(
270+
"JSON Schema for custom structured output. When set, bypasses the conversation "
271+
"format and generates directly into this schema via constrained decoding."
272+
),
273+
)
274+
266275
# Optional LLM overrides
267276
llm: LLMConfig | None = Field(
268277
default=None, description="Optional LLM configuration overrides for generation"
@@ -314,6 +323,14 @@ class OutputConfig(BaseModel):
314323
)
315324
save_as: str = Field(..., min_length=1, description="Where to save the final dataset")
316325

326+
format: Literal["messages", "custom"] = Field(
327+
default="messages",
328+
description=(
329+
"'messages' (default): OpenAI chat format. "
330+
"'custom': emit records matching generation.output_schema directly."
331+
),
332+
)
333+
317334
# Optional checkpoint configuration (nested inside output)
318335
checkpoint: CheckpointConfig | None = Field(
319336
None, description="Checkpoint configuration for resumable generation"
@@ -656,6 +673,10 @@ def get_generation_params(self, **overrides) -> dict:
656673
params["scenario_seed"] = self.generation.tools.scenario_seed
657674
params["max_agent_steps"] = self.generation.tools.max_agent_steps
658675

676+
if self.generation.output_schema:
677+
params["output_schema"] = self.generation.output_schema
678+
params["output_format"] = self.output.format
679+
659680
# Handle overrides
660681
override_provider = overrides.pop("provider", None)
661682
override_model = overrides.pop("model", None)

deepfabric/generator.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,13 @@
3434
from .error_codes import classify_error
3535
from .exceptions import DataSetGeneratorError
3636
from .llm import LLMClient
37+
from .llm.client import make_dynamic_model
3738
from .metrics import trace
3839
from .progress import ProgressReporter
3940
from .prompts import (
4041
AGENT_COT_TOOLS_PROMPT,
4142
CONVERSATION_GENERATION_PROMPT,
43+
CUSTOM_SCHEMA_PROMPT,
4244
FREETEXT_COT_PROMPT,
4345
STRUCTURED_COT_PROMPT,
4446
AgentPromptBuilder,
@@ -132,6 +134,23 @@ class DataSetGeneratorConfig(BaseModel):
132134
description="Rate limiting and retry configuration (uses provider defaults if not specified)",
133135
)
134136

137+
# Custom structured output schema
138+
output_schema: dict | None = Field(
139+
default=None,
140+
description=(
141+
"JSON Schema for custom structured output. When set, bypasses the conversation "
142+
"format and generates directly into this schema via constrained decoding. "
143+
"Records in the JSONL output will match the schema instead of the OpenAI messages format."
144+
),
145+
)
146+
output_format: Literal["messages", "custom"] = Field(
147+
default="messages",
148+
description=(
149+
"'messages' (default): OpenAI chat format. "
150+
"'custom': emit records matching output_schema directly."
151+
),
152+
)
153+
135154
# Modular conversation configuration
136155
conversation_type: Literal["basic", "cot"] = Field(
137156
default="basic",
@@ -955,6 +974,16 @@ def _get_minimal_schema(self) -> type:
955974
"""Get the conversation schema for the current config."""
956975
return get_conversation_schema(self.config.conversation_type)
957976

977+
def _get_custom_schema_model(self) -> type:
978+
"""Build a dynamic model class from the configured output_schema."""
979+
return make_dynamic_model(self.config.output_schema)
980+
981+
def _get_prompt_template(self) -> str:
982+
"""Return the prompt template, using CUSTOM_SCHEMA_PROMPT when output_schema is set."""
983+
if self.config.output_schema:
984+
return CUSTOM_SCHEMA_PROMPT
985+
return self._get_cot_prompt_template()
986+
958987
def _emit_retry(
959988
self,
960989
sample_idx: int,
@@ -1006,14 +1035,39 @@ async def _generate_structured_samples_async(
10061035
# Create a copy of config with sys_msg overridden
10071036
config = self.config.model_copy(update={"sys_msg": include_sys_msg})
10081037

1009-
async def _generate_with_retry(
1038+
async def _generate_with_retry( # noqa: PLR0911
10101039
prompt: str, sample_idx: int, topic_path_info: TopicPath | None
10111040
) -> tuple[bool, Exception | Conversation]:
10121041
"""Generate a single sample with per-sample retry for validation errors.
10131042
10141043
Each parallel task gets its own builder instance to avoid Spin session
10151044
conflicts when running samples concurrently (batch_size > 1).
10161045
"""
1046+
last_error: Exception | None = None
1047+
max_attempts = self.config.sample_retries + 1
1048+
1049+
# Custom schema mode: bypass conversation builder entirely and
1050+
# generate directly into the user-defined schema via constrained decoding.
1051+
if config.output_schema:
1052+
schema_model = self._get_custom_schema_model()
1053+
for attempt in range(max_attempts):
1054+
try:
1055+
result = await self.llm_client.generate_async(
1056+
prompt,
1057+
schema_model,
1058+
max_tokens=config.max_tokens,
1059+
)
1060+
except Exception as e: # noqa: BLE001
1061+
last_error = e
1062+
if is_validation_error(e) and attempt < self.config.sample_retries:
1063+
self._emit_retry(sample_idx, attempt, max_attempts, e)
1064+
continue
1065+
return False, last_error
1066+
else:
1067+
return True, result
1068+
return False, last_error or Exception("Custom schema generation failed")
1069+
1070+
# Normal conversation builder mode
10171071
# Create a fresh builder for this sample to avoid session conflicts
10181072
# when running in parallel batches
10191073
builder = ConversationBuilderFactory.create(
@@ -1023,9 +1077,7 @@ async def _generate_with_retry(
10231077
progress_reporter=self.progress_reporter,
10241078
)
10251079

1026-
last_error: Exception | None = None
10271080
error_feedback: str | None = None
1028-
max_attempts = self.config.sample_retries + 1
10291081
logger.debug(
10301082
"Sample %d: max_attempts=%d (sample_retries=%d)",
10311083
sample_idx + 1,
@@ -1295,7 +1347,7 @@ async def create_data_async(
12951347

12961348
# Calculate total samples requested
12971349
total_samples = num_steps * batch_size
1298-
data_creation_prompt = self._get_cot_prompt_template()
1350+
data_creation_prompt = self._get_prompt_template()
12991351

13001352
# Ensure checkpoint_interval is at least as large as concurrency/batch_size
13011353
# so checkpoints align with batch boundaries
@@ -1388,7 +1440,7 @@ async def create_data_with_events_async(
13881440

13891441
# Calculate total samples requested
13901442
total_samples = num_steps * batch_size
1391-
data_creation_prompt = self._get_cot_prompt_template()
1443+
data_creation_prompt = self._get_prompt_template()
13921444

13931445
# Ensure checkpoint_interval is at least as large as concurrency/batch_size
13941446
# so checkpoints align with batch boundaries

deepfabric/llm/client.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import json
23
import logging
34
import os
45
import sys
@@ -1218,3 +1219,40 @@ def make_async_outlines_model(provider: str, model_name: str, **kwargs) -> Any |
12181219
# Outlines does not currently expose async structured generation wrappers
12191220
# for the remaining providers. Fallback to synchronous execution later.
12201221
return None
1222+
1223+
1224+
def make_dynamic_model(schema_dict: dict) -> type:
1225+
"""Create a schema-compatible model class from a raw JSON schema dict.
1226+
1227+
The returned class satisfies the interface expected by LLMClient.generate_async:
1228+
- model_json_schema() — returns the schema dict (used by provider clients)
1229+
- model_validate_json(json_str) — parses JSON and returns an instance
1230+
- instance.model_dump() — returns the parsed dict
1231+
1232+
This lets user-defined JSON schemas flow through the existing structured-output
1233+
pipeline without requiring a hand-written Pydantic model.
1234+
"""
1235+
_schema = dict(schema_dict)
1236+
1237+
class DynamicModel:
1238+
def __init__(self, data: dict) -> None:
1239+
self._data = data
1240+
1241+
@classmethod
1242+
def model_json_schema(cls, **_kwargs: Any) -> dict:
1243+
return _schema
1244+
1245+
@classmethod
1246+
def model_validate_json(cls, json_data: str) -> "DynamicModel":
1247+
try:
1248+
data = json.loads(json_data)
1249+
except json.JSONDecodeError as exc:
1250+
raise ValueError(f"Invalid JSON from model: {exc}") from exc
1251+
return cls(data)
1252+
1253+
def model_dump(self, exclude_none: bool = False, **_kwargs: Any) -> dict:
1254+
if exclude_none:
1255+
return {k: v for k, v in self._data.items() if v is not None}
1256+
return dict(self._data)
1257+
1258+
return DynamicModel

deepfabric/prompts.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,12 @@ def build_tool_context_prompt(tool_registry, max_tools_per_query: int = 3) -> st
195195
{{{{examples}}}}
196196
{{{{subtopics}}}}"""
197197

198+
CUSTOM_SCHEMA_PROMPT = """{{{{system_prompt}}}}
199+
{{{{instructions}}}}
200+
{{{{subtopics}}}}
201+
202+
Generate a single JSON object for the topic above. Fill in all required fields accurately and completely."""
203+
198204
CONVERSATION_GENERATION_PROMPT = """Generate a training conversation for a language model with this system prompt:
199205
200206
<system_prompt>

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "DeepFabric"
3-
version = "4.12.0"
3+
version = "4.13.0"
44
description = "Curate High Quality Datasets, Train, Evaluate and Ship"
55
authors = [{name = "DeepFabric Team", email = "oss@alwaysfurther.ai"}]
66
readme = "README.md"

0 commit comments

Comments
 (0)