Skip to content

Commit fb13884

Browse files
committed
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>
1 parent a6d4678 commit fb13884

5 files changed

Lines changed: 172 additions & 49 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: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from .prompts import (
4040
AGENT_COT_TOOLS_PROMPT,
4141
CONVERSATION_GENERATION_PROMPT,
42+
CUSTOM_SCHEMA_PROMPT,
4243
FREETEXT_COT_PROMPT,
4344
STRUCTURED_COT_PROMPT,
4445
AgentPromptBuilder,
@@ -132,6 +133,23 @@ class DataSetGeneratorConfig(BaseModel):
132133
description="Rate limiting and retry configuration (uses provider defaults if not specified)",
133134
)
134135

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

976+
def _get_custom_schema_model(self) -> type:
977+
"""Build a dynamic model class from the configured output_schema."""
978+
from .llm.client import make_dynamic_model
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,
@@ -1014,6 +1043,30 @@ async def _generate_with_retry(
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+
return True, result
1061+
except Exception as e: # noqa: BLE001
1062+
last_error = e
1063+
if is_validation_error(e) and attempt < self.config.sample_retries:
1064+
self._emit_retry(sample_idx, attempt, max_attempts, e)
1065+
continue
1066+
return False, last_error
1067+
return False, last_error or Exception("Custom schema generation failed")
1068+
1069+
# Normal conversation builder mode
10171070
# Create a fresh builder for this sample to avoid session conflicts
10181071
# when running in parallel batches
10191072
builder = ConversationBuilderFactory.create(
@@ -1023,9 +1076,7 @@ async def _generate_with_retry(
10231076
progress_reporter=self.progress_reporter,
10241077
)
10251078

1026-
last_error: Exception | None = None
10271079
error_feedback: str | None = None
1028-
max_attempts = self.config.sample_retries + 1
10291080
logger.debug(
10301081
"Sample %d: max_attempts=%d (sample_retries=%d)",
10311082
sample_idx + 1,
@@ -1295,7 +1346,7 @@ async def create_data_async(
12951346

12961347
# Calculate total samples requested
12971348
total_samples = num_steps * batch_size
1298-
data_creation_prompt = self._get_cot_prompt_template()
1349+
data_creation_prompt = self._get_prompt_template()
12991350

13001351
# Ensure checkpoint_interval is at least as large as concurrency/batch_size
13011352
# so checkpoints align with batch boundaries
@@ -1388,7 +1439,7 @@ async def create_data_with_events_async(
13881439

13891440
# Calculate total samples requested
13901441
total_samples = num_steps * batch_size
1391-
data_creation_prompt = self._get_cot_prompt_template()
1442+
data_creation_prompt = self._get_prompt_template()
13921443

13931444
# Ensure checkpoint_interval is at least as large as concurrency/batch_size
13941445
# 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>

0 commit comments

Comments
 (0)