Thread existing JSON schemas from prompt definitions through the text-completion service to LLM backends' native structured output APIs. When a prompt has response-type "json" and a strict-mode compatible schema, the LLM constrains token selection at the logit level to guarantee schema-valid output. Wire-level changes: - Add response_format and schema fields to TextCompletionRequest - Update translator to encode/decode new fields - Pass new fields through LlmService, TextCompletionClient, and PromptManager Runtime schema compatibility checker: - New is_strict_mode_compatible() utility validates schemas against LLM provider constraints (additionalProperties, required fields, no unsupported constraints, no open-ended objects) - Per-prompt eligibility decision: compliant schemas use structured output, non-compliant schemas fall back to free-text + post-hoc validation LLM backend implementations: - OpenAI: response_format with json_schema, variant-aware top-level array rejection (openai variant blocks, llama/vllm variants allow) - New vllm variant for the OpenAI backend - vLLM (dedicated): response_format in raw HTTP body - Ollama: format=<schema> parameter - Claude: tool-use trick (forced tool call with schema as input_schema) - Mistral: native json_schema response_format - Llamafile, LM Studio: OpenAI SDK response_format - Azure OpenAI: AzureOpenAI SDK response_format - Azure serverless: response_format in raw HTTP body - TGI: response_format in raw HTTP body - VertexAI Gemini: response_mime_type + response_schema - VertexAI Claude: tool-use trick - Google AI Studio: response_mime_type + response_schema - Bedrock, Cohere: signature-only (no structured output yet) Post-hoc jsonschema.validate() retained as defence-in-depth. Tech spec added: docs/tech-specs/structured-output.md Update tests
11 KiB
| layout | title | parent |
|---|---|---|
| default | Structured Output: LLM-Native JSON Schema Enforcement | Tech Specs |
Structured Output: LLM-Native JSON Schema Enforcement
Problem / Opportunity
TrustGraph's knowledge-graph pipeline relies on LLMs to produce structured JSON output — entity extractions, relationship triples, topic classifications, and other schema-governed artefacts. Today, the correct structure is requested via natural-language instructions embedded in the prompt template: the prompt describes the expected JSON shape, and the system parses the LLM's free-text response, hoping it conforms.
This approach has several weaknesses:
-
Fragile parsing. LLM responses may include markdown fencing, preamble text, trailing commentary, or minor schema violations (missing fields, wrong types, extra keys). Every consumer must tolerate or work around these deviations, adding defensive code and retry logic.
-
Wasted tokens and latency. A significant portion of each prompt is spent describing the output format in prose. When the model deviates, retries consume additional tokens and add end-to-end latency.
-
Silent data-quality issues. Malformed responses that pass lenient parsing can inject bad data into the knowledge graph — wrong types, truncated lists, invented field names — without raising errors.
-
Untapped LLM capability. Most modern LLMs (OpenAI, Google Gemini, Anthropic Claude, Ollama-hosted models via llama.cpp) support structured output or guided decoding: the caller supplies a JSON schema and the model constrains token selection at the logit level to guarantee schema-valid output. TrustGraph already defines the required JSON schemas inside its prompt definitions but does not pass them through to the LLM backend.
Opportunity
By threading the existing JSON schemas from prompt definitions through the text-completion service to each LLM backend's native structured-output API, TrustGraph can:
- Guarantee valid output on every call — no parsing heuristics, no retries for format errors.
- Reduce prompt size by removing prose format instructions that the schema makes redundant.
- Improve data quality in the knowledge graph by eliminating an entire class of silent ingestion errors.
- Simplify service code by removing defensive JSON extraction and validation logic from every consumer.
Scope
Prompt definitions declare a response-type of "text", "json",
or "jsonl". Structured output applies only to prompts that produce
machine-readable output ("json" and "jsonl").
JSONL presents a compatibility challenge: LLM structured-output APIs enforce a single top-level JSON schema, but JSONL prompts ask the model to emit one JSON object per line — a format that is not itself valid JSON. Converting JSONL prompts to request a JSON array would conflict with the prompt text and sacrifice truncation resilience (partial JSONL is recoverable line-by-line; a truncated array is broken JSON).
This spec takes a three-phase approach:
- Phase 1 — plumb schemas through to LLM backends with automatic compatibility detection; non-compliant schemas fall back to the current free-text path.
- Phase 2 — fix up non-compliant schemas so more prompts benefit.
- Phase 3 — address JSONL prompts.
Phase 1 — Structured Output with Automatic Fallback
Design
Phase 1 threads the JSON schema from the prompt definition through
the text-completion service to the LLM backend's native
structured-output API. Only prompts with response-type: "json" are
candidates.
Not all existing schemas are compatible with LLM structured-output APIs. Rather than require schema changes up front, Phase 1 includes a runtime compatibility check: if a schema passes, structured output is used; if not, the prompt falls back to the current free-text path with post-hoc validation. This makes the feature safe to deploy immediately.
Strict-Mode Schema Requirements
LLM providers impose constraints beyond standard JSON Schema validation. A schema is considered strict-mode compatible when:
- Every
objecthasadditionalProperties: false. - Every property defined in
propertiesappears inrequired. Optional fields use a nullable type (e.g."type": ["string", "null"]) instead of omitting the key fromrequired. - No
minimum,maximum,minLength,maxLength, orpatternconstraints (unsupported by most providers' constrained decoders). - No open-ended objects (
{"type": "object"}withoutproperties). - A schema is present and non-null.
Runtime Compatibility Check
PromptManager (or a shared utility) inspects each schema at load
time against the strict-mode rules above. The result is a boolean
flag per prompt: structured_output_eligible.
- Eligible —
response_formatandschemaare set on theTextCompletionRequest; the LLM enforces the schema at generation time. - Not eligible — request is sent without schema fields; the
current free-text parsing and
jsonschema.validate()path is used.
This is a per-prompt decision, not a global switch.
Text-Completion Request Changes
TextCompletionRequest gains two optional fields:
TextCompletionRequest:
system: str
prompt: str
streaming: bool
response_format: str | None # "json" or None (default)
schema: dict | None # JSON Schema object or None
When response_format is "json" and schema is provided, the LLM
backend MUST use its native structured-output mechanism. When either
field is absent or null, behaviour is unchanged.
LLM Backend Mapping
Each backend maps response_format + schema to its provider's
native API:
| Backend | API mechanism |
|---|---|
| OpenAI | response_format={"type": "json_schema", "json_schema": {"name": "...", "schema": ...}} |
| Claude | tool_use with a single tool whose input_schema is the target schema, or the response_format parameter when available |
| Gemini | response_mime_type="application/json" + response_schema=... |
| Ollama | format="json" + schema in the format field (llama.cpp guided decoding) |
| Llamafile | response_format={"type": "json_object"} + schema |
Backends that do not support schema-level enforcement (e.g. older
Ollama versions) fall back to response_format=json without a schema
and rely on post-hoc validation.
Current Prompt Compatibility
Of the nine response-type: "json" prompts, two are strict-mode
compatible today:
| Prompt | Status | Issue |
|---|---|---|
schema-selection |
Ready | — |
supervisor-decompose |
Ready | — |
plan-create |
Fixable | Optional fields not in required |
graphql-generation |
Blocked | Open-ended variables object; minimum/maximum on confidence |
plan-step-execute |
Blocked | Open-ended arguments object |
diagnose-structured-data |
No schema | — |
diagnose-xml |
No schema | — |
diagnose-json |
No schema | — |
diagnose-csv |
No schema | — |
What Does Not Change
- Prompt templates and their text content.
- The
"text"and"jsonl"response-type paths. - The
TextCompletionResponseschema. - Post-hoc validation (retained as a defence-in-depth measure).
Phase 2 — Schema Remediation
Phase 2 expands structured-output coverage by fixing schemas that failed the Phase 1 compatibility check.
Fixable Schemas
plan-create — tool_hint and depends_on are optional
(present in properties but absent from required). Fix: add both
to required and change their types to nullable:
"tool_hint": {"type": ["string", "null"]},
"depends_on": {
"type": ["array", "null"],
"items": {"type": "integer"}
}
Schemas Requiring Design Decisions
graphql-generation — Two issues:
variablesis an open-ended object ("additionalProperties": true) with no fixed properties. Constrained decoding cannot handle arbitrary keys. Options: removevariablesfrom the schema and accept it as free-form text within a wrapper, or restructure as a JSON-encoded string field.confidenceuses"minimum": 0.0, "maximum": 1.0. Fix: remove the numeric bounds; accept any number and clamp in application code if needed.
plan-step-execute — arguments is an open-ended object with no
fixed properties. Same constraint as graphql-generation.variables.
Missing Schemas
The four diagnose-* prompts have response-type: "json" but no
schema. Adding schemas for these prompts would bring them into
structured-output scope. This requires defining the expected
response shape for each diagnostic prompt.
Phase 3 (Future) — Structured Output for JSONL Prompts
JSONL prompts ask the LLM to emit multiple JSON objects, one per line. Each object is validated individually against the prompt's schema. The current approach is tolerant of truncation and malformed lines — useful properties for large extractions.
Options
Option A — Array wrapper. Change the prompt text to request a
JSON array instead of JSONL. Wrap the schema as
{"type": "array", "items": <existing-schema>} and use structured
output. Trade-off: loses line-by-line truncation resilience; requires
updating every JSONL prompt template.
Option B — Structured output per chunk. Split the input so each text-completion call produces a single JSON object, then aggregate. Trade-off: more LLM calls; higher latency and cost; may not suit prompts that extract variable-length lists from a single chunk.
Option C — Hybrid. Use structured output with the array-wrapped schema but retain the post-hoc JSONL parser as a fallback for backends that do not support structured output or when the response is truncated. Trade-off: two code paths to maintain.
Option D — Status quo. Leave JSONL prompts on the free-text path
with post-hoc validation. Structured output for "json" prompts
already covers the most schema-sensitive cases; JSONL extraction is
inherently more tolerant of partial results.
Phase 3 design will be selected after earlier phases are deployed and real-world structured-output behaviour is observed across backends.