TramAI

Tutorial: Build an Invoice Analyzer

This tutorial walks through a complete Invoice Analyzer from zero to running with a test. You will learn how to add dependencies, define a typed AI service, wire a provider, add observability, and test deterministically.

Goal: Build an invoice analyzer that turns messy invoice text into typed data, in under 20 minutes.

What You Are Building

You will build an InvoiceAnalyzer service that takes raw invoice text and returns a structured InvoiceDecision object. The final application code looks like this:

val result = analyzer.analyze(invoiceText)
println("Status: ${result.status}, Vendor: ${result.vendor}")
InvoiceDecision result = analyzer.analyze(invoiceText);
System.out.println("Status: " + result.status() + ", Vendor: " + result.vendor());

No JSON parsing. No markdown fences to strip. No hand-written retry loops.

Step 1: Add Dependencies

Gradle (Kotlin DSL)

dependencies {
    implementation(platform("dev.tramai:tramai-bom:0.4.0"))
    implementation("dev.tramai:tramai-standalone")
    implementation("dev.tramai:tramai-openai")

    testImplementation(platform("dev.tramai:tramai-bom:0.4.0"))
    testImplementation("dev.tramai:tramai-testing")
}

Maven

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>dev.tramai</groupId>
      <artifactId>tramai-bom</artifactId>
      <version>0.4.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>dev.tramai</groupId>
    <artifactId>tramai-standalone</artifactId>
  </dependency>
  <dependency>
    <groupId>dev.tramai</groupId>
    <artifactId>tramai-openai</artifactId>
  </dependency>
  <dependency>
    <groupId>dev.tramai</groupId>
    <artifactId>tramai-testing</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

What these do: tramai-standalone gives you the DSL builder without Spring. tramai-openai provides the OpenAI provider adapter. tramai-testing gives you mock providers for deterministic tests.

Minimum setup: These three coordinates, one provider API key, and Java 21+.

Step 2: Define the InvoiceAnalyzer Service

Define a typed contract. This is the heart of TramAI.

Kotlin

import dev.tramai.core.annotations.AiService
import dev.tramai.core.annotations.Operation
import dev.tramai.core.annotations.AiDescription

data class InvoiceDecision(
    @property:AiDescription("One of CURRENT, DUE_SOON, OVERDUE, DISPUTED, or UNKNOWN")
    val status: String,

    @property:AiDescription("Vendor name when present in the input")
    val vendor: String?,

    @property:AiDescription("Amount exactly as it appears in the invoice text")
    val amountText: String?,

    @property:AiDescription("Primary next action the accounts-payable team should take")
    val nextAction: String,
)

@AiService
interface InvoiceAnalyzer {
    @Operation(
        prompt = "Analyze the invoice text and return a structured payment decision.",
        model = "gpt-4o",
    )
    suspend fun analyze(invoiceText: String): InvoiceDecision
}

Java

import dev.tramai.core.annotations.AiDescription;
import dev.tramai.core.annotations.AiService;
import dev.tramai.core.annotations.Operation;

public record InvoiceDecision(
    @AiDescription("One of CURRENT, DUE_SOON, OVERDUE, DISPUTED, or UNKNOWN")
    String status,

    @AiDescription("Vendor name when present in the input")
    String vendor,

    @AiDescription("Amount exactly as it appears in the invoice text")
    String amountText,

    @AiDescription("Primary next action the accounts-payable team should take")
    String nextAction
) {}

@AiService
public interface InvoiceAnalyzer {
    @Operation(
        prompt = "Analyze the invoice text and return a structured payment decision.",
        model = "gpt-4o"
    )
    InvoiceDecision analyze(String invoiceText);
}

What this does: TramAI generates a JSON schema from InvoiceDecision, injects it into the prompt, sends the request, extracts JSON from the response, deserializes it into your type, and retries if parsing fails.

Key rules:

  • @AiService on an interface — TramAI generates a proxy at runtime
  • @Operation with model = "..." — the model name MUST match a registration in your Tramai builder
  • suspend in Kotlin (methods are I/O-bound); blocking in Java (no suspend needed)
  • Data classes / records — the return type drives schema generation

Step 3: Write the Main Function

Wire a provider, register a model, create the service, and call it.

Kotlin

import dev.tramai.openai.OpenAiProvider
import dev.tramai.standalone.Tramai

suspend fun main() {
    val tramai = Tramai {
        provider(
            OpenAiProvider(apiKey = System.getenv("OPENAI_API_KEY")),
            name = "openai",
            default = true,
        )
        model("gpt-4o", "openai")
    }

    val analyzer = tramai.create<InvoiceAnalyzer>()

    val result = analyzer.analyze(
        """
        Vendor: Northwind Power
        Invoice: INV-1042
        Amount due: 4820 USD
        Due date: 2026-04-30
        Status: 12 days overdue
        """.trimIndent(),
    )

    println("Status: ${result.status}")
    println("Vendor: ${result.vendor}")
    println("Amount: ${result.amountText}")
    println("Next action: ${result.nextAction}")
}

Java

import dev.tramai.openai.OpenAiProvider;
import dev.tramai.standalone.Tramai;

public class Main {
    public static void main(String[] args) {
        Tramai tramai = Tramai.builder()
            .provider(
                new OpenAiProvider(System.getenv("OPENAI_API_KEY")),
                "openai",
                true
            )
            .model("gpt-4o", "openai")
            .build();

        InvoiceAnalyzer analyzer = tramai.create(InvoiceAnalyzer.class);

        InvoiceDecision result = analyzer.analyze(
            """
            Vendor: Northwind Power
            Invoice: INV-1042
            Amount due: 4820 USD
            Due date: 2026-04-30
            Status: 12 days overdue
            """
        );

        System.out.println("Status: " + result.status());
        System.out.println("Vendor: " + result.vendor());
        System.out.println("Amount: " + result.amountText());
        System.out.println("Next action: " + result.nextAction());
    }
}

What happens when you call analyze:

  1. TramAI builds a prompt from your @Operation text and generates a JSON schema from InvoiceDecision
  2. It sends the prompt + schema to gpt-4o via OpenAI
  3. It extracts JSON from the response (handles markdown fences automatically)
  4. It deserializes into InvoiceDecision
  5. If parsing fails, it retries with structured feedback (default: 2 retries = 3 total attempts)
  6. It returns a typed InvoiceDecision object

To run: Set OPENAI_API_KEY environment variable and run your main class.

Step 4: Write a Deterministic Test

Use tramai-testing to test your service without network calls.

Kotlin

import dev.tramai.testing.MockAiProvider
import dev.tramai.standalone.Tramai
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals

class InvoiceAnalyzerTest {
    @Test
    fun `returns typed invoice decision`() = runBlocking {
        val provider = MockAiProvider {
            onMethod("analyze") respondWith """
                {
                  "status": "OVERDUE",
                  "vendor": "Northwind Power",
                  "amountText": "4820 USD",
                  "nextAction": "ESCALATE"
                }
            """.trimIndent()
        }

        val tramai = Tramai {
            provider(provider, name = "mock", default = true)
            model("gpt-4o", "mock")
        }

        val analyzer = tramai.create<InvoiceAnalyzer>()
        val result = analyzer.analyze("Vendor: Northwind Power\nInvoice: INV-1042")

        assertEquals(
            InvoiceDecision(
                status = "OVERDUE",
                vendor = "Northwind Power",
                amountText = "4820 USD",
                nextAction = "ESCALATE",
            ),
            result,
        )
    }
}

Java

import dev.tramai.testing.MockAiProvider;
import dev.tramai.standalone.Tramai;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class InvoiceAnalyzerTest {
    @Test
    void returnsTypedInvoiceDecision() {
        MockAiProvider provider = new MockAiProvider(builder -> {
            builder.onMethod("analyze").respondWith("""
                {
                  "status": "OVERDUE",
                  "vendor": "Northwind Power",
                  "amountText": "4820 USD",
                  "nextAction": "ESCALATE"
                }
                """);
        });

        Tramai tramai = Tramai.builder()
            .provider(provider, "mock", true)
            .model("gpt-4o", "mock")
            .build();

        InvoiceAnalyzer analyzer = tramai.create(InvoiceAnalyzer.class);
        InvoiceDecision result = analyzer.analyze("Vendor: Northwind Power\nInvoice: INV-1042");

        assertEquals("OVERDUE", result.status());
        assertEquals("Northwind Power", result.vendor());
        assertEquals("4820 USD", result.amountText());
        assertEquals("ESCALATE", result.nextAction());
    }
}

What this proves: Your application code consumes the typed contract correctly. The test runs in milliseconds, costs nothing, and requires no network access.

Testing Retry Behavior

Queue multiple responses for the same method to test recovery from malformed output:

val provider = MockAiProvider {
    onMethod("analyze") respondWith "not json"
    onMethod("analyze") respondWith """
        {
          "status": "OVERDUE",
          "vendor": "Northwind Power",
          "amountText": "4820 USD",
          "nextAction": "ESCALATE"
        }
    """.trimIndent()
}

This verifies that TramAI's structured retry loop handles parse failures and recovers before returning to your code.

Step 5: Add Observability

Add tracing and metrics using the tramai-observability module.

Add the dependency

implementation("dev.tramai:tramai-observability")

Wire the observer

import dev.tramai.observability.OpenTelemetryOperationObserver
import io.opentelemetry.api.OpenTelemetry

val openTelemetry: OpenTelemetry = /* your configured SDK */
val observer = OpenTelemetryOperationObserver(openTelemetry)

val tramai = Tramai {
    provider(
        OpenAiProvider(apiKey = System.getenv("OPENAI_API_KEY")),
        name = "openai",
        default = true,
    )
    model("gpt-4o", "openai")
    observer(observer)
}
import dev.tramai.observability.OpenTelemetryOperationObserver;
import io.opentelemetry.api.OpenTelemetry;

OpenTelemetry openTelemetry = /* your configured SDK */;
OpenTelemetryOperationObserver observer = new OpenTelemetryOperationObserver(openTelemetry);

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

What is recorded

Each provider attempt produces:

  • One OpenTelemetry span with provider, model, tokens, and retry metadata
  • Metrics: tramai.operation.attempts, tramai.operation.duration, tramai.operation.input_tokens, tramai.operation.output_tokens, tramai.operation.parse_failures

You still need to configure your own OpenTelemetry SDK and exporter pipeline (Jaeger, Prometheus, etc.).

Step 6: Run and Verify

  1. Run your test (InvoiceAnalyzerTest) first — it should pass without any API key
  2. Set OPENAI_API_KEY environment variable
  3. Run your main function
  4. Verify the output shows the structured decision

Expected output (Kotlin):

Status: OVERDUE
Vendor: Northwind Power
Amount: 4820 USD
Next action: ESCALATE

Spring Boot Variant

If you use Spring Boot, the service contract stays the same. You change only the runtime wiring.

implementation("dev.tramai:tramai-spring")
tramai:
  default-provider: openai
  models:
    gpt-4o: openai
  providers:
    openai:
      api-key: ${OPENAI_API_KEY}

The @AiService interface is auto-registered as a Spring bean. Inject it like any other dependency:

@Service
class BillingService(
    private val invoiceAnalyzer: InvoiceAnalyzer,
) {
    suspend fun review(invoiceText: String): InvoiceDecision =
        invoiceAnalyzer.analyze(invoiceText)
}

What You Built

  • One typed interface (InvoiceAnalyzer)
  • One provider mapping (OpenAI)
  • One structured output contract (InvoiceDecision)
  • One deterministic test (no network)
  • One observability setup (OpenTelemetry)

This is a production-shaped first integration. No manual JSON parsing, no regex stripping, no retry loops in application code.

Limitations

  • Structured output uses schema-in-prompt + parse-and-retry. Provider-native structured output modes are not yet integrated.
  • Streaming structured partials are not supported. Full response required before parsing.
  • The test mock requires you to know the exact JSON shape your model would produce.

Next Steps