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:
- Generates a JSON schema from the Kotlin data class or Java record
- Injects the schema into the model prompt with instructions to return JSON only
- Extracts JSON from the response (raw, fenced markdown, or embedded in text)
- Deserializes it into the return type using Jackson
- Validates the result against annotation constraints
- 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:
| Annotation | Target | Effect |
|---|---|---|
@AiDescription | Field | Adds a description to the JSON schema property |
@AiRange(min, max) | Numeric field | Adds minimum and maximum to the schema; validates after deserialization |
@AiMinItems | Collection field | Adds 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
feedbackMessagedescribing the exact failure so the model can correct itself - Provider retries (HTTP 429, network error): Controlled separately by
providerRetries
What Happens on Each Attempt
- First attempt: prompt + schema sent to model
- If response parses and validates: return result
- 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
- After exhausting
maxRetries:TramaiExceptionis 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
| Pitfall | Symptom | Fix |
|---|---|---|
| Nullable field without default | Deserialization fails when field is missing | Set a default value or add @JvmOverloads |
| Field name mismatch | Schema uses Kotlin name, model returns different casing | Use @JsonProperty or keep names simple |
| Deeply nested graphs | Schema becomes huge, models produce partial output | Flatten to 2-3 levels max |
| Ambiguous string fields | result, value, statusText, finalAnswer all in one class | Use explicit field names that describe the domain |
| List without item constraint | Model returns 0 items when you expected at least 1 | Use @AiMinItems(1) |
| Primitive vs boxed | Kotlin Int (non-null) vs Int? (nullable) changes schema | Be 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
