DEV Community

Solon Framework
Solon Framework

Posted on

From Messy Text to Reliable Java Objects - Structured Output Extraction with Solon AI

Every enterprise has a pile of documents that machines cannot read yet: supplier invoices arriving as free-text emails, résumés in a dozen formats, support tickets, contract clauses. The downstream systems - ERP, ATS, CRM - all want clean, typed data. Bridging that gap has traditionally meant brittle regex farms or template matchers that break on the first layout change.

Large language models are obviously good at reading this text. The unsolved part is the output contract. If you just ask a model "return JSON", you get JSON decorated with markdown fences, occasional prose preambles, missing fields, or a friendly sentence explaining why it could not comply. That is fine for a demo and fatal for a database insert.

This article shows how Solon AI (v4.0.5) treats structured extraction as a first-class capability: you declare the target as a plain Java type, the framework generates the JSON Schema, injects it into the conversation, extracts the JSON from whatever the model actually returned, and deserializes it back into your bean - with retries, sessions and agents when you need them.

The problem with "please return JSON"

The common workaround is prompt engineering:

Extract the invoice fields and return ONLY valid JSON matching: {invoice_no, total, currency, ...}
Enter fullscreen mode Exit fullscreen mode

Three failure modes in production:

  1. Decoration drift. Models wrap output in ```json fences, add "Here is the JSON:", or both. Your parser dies on character one.
  2. Schema amnesia. Long prompts, many fields - the model forgets tax_rate or invents taxRate. Nothing catches it before the INSERT.
  3. Provider lock-in. Some vendors offer native JSON mode or response_format with strict schemas. Your extraction code then only works with that one vendor, and the migration cost multiplies across every extraction job you own.

Solon AI addresses all three with a type-driven pipeline that runs on any ChatModel.

Step 1: Declare the target type

Extraction targets are ordinary Java beans. Fields can carry @Param annotations to enrich the generated schema with descriptions, required flags and defaults:


java
public class InvoiceInfo {
    @Param(description = "Invoice number, e.g. INV-2025-0311", required = true)
    public String invoiceNo;

    @Param(description = "Vendor legal name", required = true)
    public String vendor;

    @Param(description = "Total amount in minor currency units", required = true)
    public Long totalAmount;

    @Param(description = "ISO-4217 currency code")
    public String currency;

    @Param(description = "Due date in yyyy-MM-dd")
    public String dueDate;

    @Param(description = "Extracted line items")
    public LineItem[] items;

    public static class LineItem {
        @Param(description = "Item description")
        public String description;

        @Param(description = "Quantity", required = true, defaultValue = "1")
        public Integer quantity;

        @Param(description = "Unit price in minor units")
        public Long unitPrice;
    }
}


Enter fullscreen mode Exit fullscreen mode

Why descriptions matter: they end up inside the JSON Schema that the model reads. A field named totalAmount is ambiguous (gross? net? VAT included? major or minor units?). The schema text is the cheapest place to remove that ambiguity - cheaper than debugging wrong numbers in your ledger.

Step 2: Attach the schema and call

With just the core chat API, the schema rides on the request options:


java
ChatModel chatModel = ChatModel.of("http://localhost:11434/api/chat")
        .apiKey(System.getenv("LLM_API_KEY"))
        .model("qwen3:14b")
        .build();

ChatResponse resp = chatModel
        .prompt(invoiceEmailText)
        .options(o -> o.outputSchema(InvoiceInfo.class))
        .call();

InvoiceInfo invoice = resp.getMessage().toBean(InvoiceInfo.class);

System.out.println(invoice.invoiceNo + " -> " + invoice.totalAmount);


Enter fullscreen mode Exit fullscreen mode

That is the whole extraction call. Three things happened under the hood worth understanding, because they are the difference between this and prompt-begging.

How the schema reaches the model

When outputSchema is set, the request pipeline hands the schema string to the active chat dialect, which appends it to the instruction block:


json
<output_schema>
{"type":"object","properties":{"invoiceNo":{"type":"string","description":"Invoice number, e.g. INV-2025-0311"}, ...},"required":["invoiceNo","vendor","totalAmount", ...]}
</output_schema>


Enter fullscreen mode Exit fullscreen mode

The schema itself is generated from the Java type by the framework (snack4's JsonSchema generator walks the TypeEggg metadata of your bean, consuming the @Param annotations along the way). Primitive wrappers, strings, enums and simple types don't even need a schema - only structured objects do, so your prompt stays minimal when a type degenerates to a string.

Because the injection happens through the ChatDialect extension point, every provider benefits - Ollama, OpenAI, DashScope, Gemini, Anthropic - and a vendor with a native strict JSON mode could override the hook. Your extraction code never branches on provider capabilities.

How the answer comes back

Models will still decorate. AssistantMessage.getJsonContent() strips the noise: it locates the first { or [ and the matching last closing brace, so markdown fences, "Here is the JSON:" preambles and trailing commentary are all tolerated. toBean() then deserializes the extracted JSON into the target type. You can also call getResultContent() to get thinking-stripped raw text if you want to run your own validation.

What you do NOT get for free

Honesty section: a schema improves field discipline dramatically, but it cannot make a small model do reliable arithmetic. For invoice totals, compute from line items in Java; use extraction only to read, not to sum. Validation belongs in your code:


java
if (invoice.totalAmount == null || invoice.items == null || invoice.items.length == 0) {
    throw new ExtractionException("invoice fields missing: " + invoice.invoiceNo);
}


Enter fullscreen mode Exit fullscreen mode

Step 3: Promote to an extraction agent

Single calls are fine for one document. A production pipeline processing hundreds of résumés per day wants retries, low temperature, and a place to collect results. This is where SimpleAgent earns its name:


java
SimpleAgent resumeAgent = SimpleAgent.of(chatModel)
        .name("ResumeExtractor")
        .role("HR assistant specialized in résumé parsing")
        .instruction("Extract the key facts from the candidate text provided by the user")
        .outputSchema(ResumeInfo.class)
        .outputKey("extracted_resume")
        .retryConfig(3, 2000L)
        .modelOptions(o -> o.temperature(0.1F))
        .build();

public static class ResumeInfo {
    public String name;
    public Integer age;
    public String email;
    public String[] capabilities;
}


Enter fullscreen mode Exit fullscreen mode

Running it against a batch:


java
AgentSession session = InMemoryAgentSession.of("resume-batch-01");

for (String rawResume : inboundResumes) {
    AssistantMessage message = resumeAgent
            .prompt(Prompt.of(rawResume))
            .session(session)
            .call()
            .getMessage();

    // Path A: typed bean directly
    ResumeInfo info = message.toBean(ResumeInfo.class);

    // Path B: same result, stored in the session context under outputKey
    String extractedJson = (String) session.getContext().get("extracted_resume");

    atsService.upsert(info);
}


Enter fullscreen mode Exit fullscreen mode

The knobs that matter for extraction jobs:

  • outputSchema(ResumeInfo.class) - same type-driven schema as the raw ChatModel path.
  • outputKey("extracted_resume") - the agent also writes the extracted JSON into the session context, so downstream steps (an enrichment agent, a human reviewer, an export job) can pick it up without re-parsing the message.
  • retryConfig(3, 2000L) - up to 3 retries with a 2s delay. Schema violations and unparseable output are exactly the transient failures retries absorb.
  • modelOptions(o -> o.temperature(0.1F)) - extraction is reading, not writing poetry. Low temperature measurably reduces format drift.

The same builder surface exists on ReActAgent for extraction jobs that need tools first - say, the agent must call a country-code lookup service before it can normalize a phone number, then emit the final structured result.

Where the pieces live

For reference, the moving parts in the Solon AI modules:

Capability Module Entry point
Schema generation from Java types solon-ai-core ToolSchemaUtil.buildOutputSchema(Type)
Schema injection per provider solon-ai-core dialects ChatDialect.prepareOutputSchemaInstruction(...)
JSON stripping + deserialization solon-ai-core AssistantMessage.getJsonContent() / toBean(Type)
Agent pipeline (retry, outputKey) solon-ai-agent SimpleAgent / ReActAgent builders

Dependencies for a minimal extraction service:


xml
<dependency>
    <groupId>org.noear</groupId>
    <artifactId>solon-ai-core</artifactId>
</dependency>
<dependency>
    <groupId>org.noear</groupId>
    <artifactId>solon-ai-dialect-ollama</artifactId>
</dependency>
<!-- or: solon-ai-dialect-openai / dashscope / gemini / anthropic -->
<dependency>
    <groupId>org.noear</groupId>
    <artifactId>solon-ai-agent</artifactId>
</dependency>


Enter fullscreen mode Exit fullscreen mode

Beyond invoices and résumés

The same pattern generalizes to any text-to-record workload:

  • Support tickets → structured incident records (severity, product area, entitlement) feeding your ticketing API.
  • Contract clauses → obligation registers with counterparty, deadline, penalty fields for a compliance dashboard.
  • Sensor log excerpts → fault codes for maintenance planning, where the "text" is semi-structured machine output.
  • Agent-to-agent handoff - one agent's structured output is the next agent's typed input, with outputKey as the contract slot.

When your extraction job outgrows a single call, the schema composes with the rest of the Solon AI stack: wrap it in a TeamAgent workflow with a human-in-the-loop checkpoint for low-confidence records, or let a Loop-based validator re-prompt with the validation error until the bean passes - the schema gives the loop a concrete failure signal to work with.

Wrapping up

Structured extraction fails in production for boring reasons: fences around the JSON, renamed fields, vendor-specific JSON modes. Solon AI's answer is to make the Java type the single source of truth - generate the schema from it, transport it in a provider-neutral way, strip whatever decoration the model adds, and deserialize back into the same type that generated the contract. Add retries and low temperature at the agent layer, and the gap between "the model can read it" and "the database can accept it" closes to a few lines of code.

The framework is open source, Java 8 to 26, and the docs live at solon.noear.org.

Top comments (0)