Tramai Logo
Tramai

Structured Output

What it is: TramAI automatically generates a JSON schema from your data class or Java record, injects it into the model prompt, and deserializes the response into your type. If parsing or validation fails, the engine retries with corrective feedback.

When to use it: Any time you need a typed, validated result from an AI call — classification, extraction, summarization, decision output — instead of a raw string.

How It Works

When a service method returns a non-String non-Unit type, TramAI performs these steps automatically:

  1. Generates a JSON schema from the Kotlin data class or Java record
  2. Injects the schema into the model prompt with instructions to return JSON only
  3. Extracts JSON from the response (raw, fenced markdown, or embedded in text)
  4. Deserializes it into the return type using Jackson
  5. Validates the result against annotation constraints
  6. If parsing or validation fails, retries with structured feedback describing what went wrong

The retry loop lives in the engine. The schema generation and validation live in the structured output module (tramai-structured). That separation is deliberate — providers can be swapped without affecting how schemas are generated or validated.

Minimum Setup

Add the dependency:

// build.gradle.kts
dependencies {
    implementation("dev.tramai:tramai-standalone:0.3.1")
    implementation("dev.tramai:tramai-openai:0.3.1")
}

Define your data class and service interface:

data class TicketSummary(
   val severity: String,
   val owner: String,
   val actions: List<String>,
)

@AiService
interface IncidentAnalyzer {
   @Operation(
       prompt = "Analyze the incident and return a structured summary",
       model = "gpt-4o",
   )
   suspend fun analyze(incident: String): TicketSummary
}

Wire it up and call it:

val tramai = Tramai {
   provider(OpenAiProvider(System.getenv("OPENAI_API_KEY")), name = "openai", default = true)
   model("gpt-4o", "openai")
}
val service = tramai.create<IncidentAnalyzer>()
val result = service.analyze("P1 outage in us-east-1")
println(result.severity) // "critical"

Validation Annotations

TramAI supports these annotations on data class fields to constrain the generated schema and validate responses:

AnnotationTargetEffect
@AiDescriptionFieldAdds a description to the JSON schema property
@AiRange(min, max)Numeric fieldAdds minimum and maximum to the schema; validates after deserialization
@AiMinItemsCollection fieldAdds minItems to the schema; validates collection size

Example:

data class SpendAnalysis(
   @property:AiDescription("Total spend in USD")
   @property:AiRange(min = 0.0, max = 1_000_000.0)
   val totalSpend: Double,

   @property:AiDescription("Ordered cost reduction recommendations")
   @property:AiMinItems(1)
   val recommendations: List<String>,

   @property:AiDescription("Confidence between 0 and 1")
   @property:AiRange(min = 0.0, max = 1.0)
   val confidence: Double,
)

Parse-and-Retry Behavior

The retry loop is controlled by maxRetries on @Operation:

@Operation(
   prompt = "Return a structured classification",
   model = "gpt-4o",
   maxRetries = 4,  // up to 5 total attempts
)
  • Default: maxRetries = 2, meaning up to 3 total attempts
  • Retry trigger: Structured parsing fails (invalid JSON, wrong shape) or validation fails (range violation, missing required field)
  • Feedback: Each retry includes a feedbackMessage describing the exact failure so the model can correct itself
  • Provider retries (HTTP 429, network error): Controlled separately by providerRetries

What Happens on Each Attempt

  1. First attempt: prompt + schema sent to model
  2. If response parses and validates: return result
  3. If response fails: engine constructs a corrective message ("Your previous response failed validation: Property 'confidence' must be between 0.0 and 1.0. Return corrected JSON only.") and retries
  4. After exhausting maxRetries: TramaiException is thrown

JSON Extraction Behavior

TramAI's JSON parser tolerates these response formats:

  • Raw JSON: {"severity": "critical", ...}
  • Fenced markdown: json\n{"severity": "critical"}\n
  • Embedded JSON: Text surrounding a JSON object or array

This makes the parser resilient to common formatting drift across providers.

Kotlin + Java Examples

Kotlin

// Kotlin data class
data class SentimentScore(
   @property:AiDescription("Overall sentiment: positive, negative, or neutral")
   val sentiment: String,
   @property:AiRange(min = 0.0, max = 1.0)
   val confidence: Double,
)

@AiService
interface SentimentAnalyzer {
   @Operation(prompt = "Analyze the sentiment", model = "gpt-4o")
   suspend fun analyze(text: String): SentimentScore
}

val service = tramai.create<SentimentAnalyzer>()
val result = runBlocking { service.analyze("Amazing product!") }
println("${result.sentiment} (${result.confidence})")

Java

// Java record
public record SentimentScore(
   @AiDescription("Overall sentiment: positive, negative, or neutral")
   String sentiment,
   @AiRange(min = 0.0, max = 1.0)
   double confidence
) {}

@AiService
public interface SentimentAnalyzer {
   @Operation(prompt = "Analyze the sentiment", model = "gpt-4o")
   SentimentScore analyze(String text);
}

// Usage
Tramai tramai = Tramai.builder()
   .provider(new OpenAiProvider(System.getenv("OPENAI_API_KEY")), "openai", true)
   .model("gpt-4o", "openai")
   .build();

SentimentAnalyzer analyzer = tramai.create(SentimentAnalyzer.class);
SentimentScore result = analyzer.analyze("Amazing product!");
System.out.println(result.sentiment() + " (" + result.confidence() + ")");

Common Pitfalls

PitfallSymptomFix
Nullable field without defaultDeserialization fails when field is missingSet a default value or add @JvmOverloads
Field name mismatchSchema uses Kotlin name, model returns different casingUse @JsonProperty or keep names simple
Deeply nested graphsSchema becomes huge, models produce partial outputFlatten to 2-3 levels max
Ambiguous string fieldsresult, value, statusText, finalAnswer all in one classUse explicit field names that describe the domain
List without item constraintModel returns 0 items when you expected at least 1Use @AiMinItems(1)
Primitive vs boxedKotlin Int (non-null) vs Int? (nullable) changes schemaBe explicit about nullability

Limitations

  • Provider-native structured output: Not yet integrated (OpenAI response_format, Anthropic tool-use structured). The current approach is schema-in-prompt + parse-and-retry.
  • Streaming structured partials: Not supported. Structured output requires the full response before parsing.
  • Schema version negotiation: Not yet supported. The schema is always generated from the current data class definition.
  • Map types: Map<K, V> fields are not supported as structured output types. Use a data class wrapper or a list of entries instead.

Next Steps

  • Tool Calling — Combine structured output with tool execution
  • Testing — Test structured output deterministically with mock providers
  • Observability — Monitor parse failure rates and token usage