trustgraph/docs/tech-specs/structured-output.md
cybermaggedon 9136526863
feat: LLM-native structured output via JSON schema enforcement (#1037)
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
2026-07-10 15:28:56 +01:00

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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 object has additionalProperties: false.
  • Every property defined in properties appears in required. Optional fields use a nullable type (e.g. "type": ["string", "null"]) instead of omitting the key from required.
  • No minimum, maximum, minLength, maxLength, or pattern constraints (unsupported by most providers' constrained decoders).
  • No open-ended objects ({"type": "object"} without properties).
  • 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.

  • Eligibleresponse_format and schema are set on the TextCompletionRequest; 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 TextCompletionResponse schema.
  • 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-createtool_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:

  • variables is an open-ended object ("additionalProperties": true) with no fixed properties. Constrained decoding cannot handle arbitrary keys. Options: remove variables from the schema and accept it as free-form text within a wrapper, or restructure as a JSON-encoded string field.
  • confidence uses "minimum": 0.0, "maximum": 1.0. Fix: remove the numeric bounds; accept any number and clamp in application code if needed.

plan-step-executearguments 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.