TramAI - governed AI workflows for Java and Kotlin

Structured Output for Java and Kotlin AI Applications

One of the fastest ways AI integrations become fragile is when the application expects structure but the framework only gives you free-form text.

In practice, Java and Kotlin teams usually need the model to return something that can be used directly in application code:

  • a typed result
  • a validated schema
  • a known contract
  • predictable failure behavior when the output does not match

Why Structured Output Matters

Without structured output, teams often end up with:

  • string parsing in business logic
  • fragile prompt contracts
  • difficult retries and recovery
  • inconsistent behavior between providers
  • poor testability

How TramAI Approaches It

When an @AiService method returns anything other than String or Unit, TramAI:

  1. compiles the return type into a schema-like JSON contract — from a Kotlin data class or a Java record/bean
  2. injects that contract into the prompt and asks for JSON
  3. extracts the JSON from the response (raw JSON, fenced code blocks, and JSON embedded in surrounding text all parse)
  4. deserializes it into the typed return value
  5. validates it, including the constraint annotations below
  6. retries with structured feedback when parsing or validation fails, replaying the broken response plus a correction message
dependencies {
    implementation(platform("dev.tramai:tramai-bom:0.6.0"))
    implementation("dev.tramai:tramai-standalone")   // or tramai-spring-boot-starter
    implementation("dev.tramai:tramai-openai")
}
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>,
)

@AiService
interface SpendAnalyzer {
    @Operation(
        prompt = "Analyze the spend report and return a structured summary",
        model = "gpt-4o",
        maxRetries = 4,
    )
    suspend fun analyze(report: String): SpendAnalysis
}

@AiDescription, @AiRange(min =, max =), and @AiMinItems(n) are the constraint annotations supported in 0.6.0. maxRetries on @Operation controls structured retries only — the default is 2, meaning up to three attempts including the first — while provider transport failures are controlled separately by providerRetries. The schema compiler and validator live in tramai-structured; the retry loop lives in the engine.

The integration stays aligned with the language and application model instead of drifting into prompt-specific parsing code.

What a Typed Failure Looks Like

When structured attempts are exhausted, TramAI raises StructuredOutputException carrying a typed failureCode (CONTRACT_FAILED, OUTPUT_REJECTED, REPAIR_EXHAUSTED, HANDLER_FAILED) and the attemptCount:

try {
    spendAnalyzer.analyze(report)
} catch (e: StructuredOutputException) {
    logger.warn("Structured output failed: code={} attempts={}", e.failureCode, e.attemptCount)
    // Route to your fallback path
}

The public message is fixed and sanitized. Raw provider text and validation detail are delivered to a StructuredOutputFailureDiagnosticObserver when one is configured, not through the exception message — so failure handling is typed and diagnosable without leaking model output into logs.

Good Fit Examples

  • invoice extraction
  • support ticket triage
  • review and sentiment classification
  • compliance and risk labeling
  • action planning from business documents

Why This Is Important For JVM Teams

Kotlin and Java applications are typically built around explicit contracts, stable interfaces, and maintainable business logic. Structured output aligns AI behavior with those expectations instead of forcing the application to absorb ambiguity at runtime.

Continue