ChatClient.CallResponseSpec.entity() mapping LLM JSON to a record (TicketTriage, with a real enum-constrained Priority field), a List<ActionItem>, and a Map<String,Object> -- every case driven by a hand-written ScriptedChatModel with no live LLM anywhere. Key findings, all confirmed by disassembling spring-ai-client-chat-2.0.1.jar and spring-ai-model-2.0.1.jar rather than trusting docs: - StructuredOutputValidationAdvisor lives in org.springframework.ai.chat.client.advisor, in the same spring-ai-client-chat artifact as ToolCallingAdvisor -- unlike the tool-calling module's Tool Search Advisor pieces, it needs no separate Maven Central artifact or version pin. - entity(Class, spec -> spec.validateSchema()) is sugar: DefaultCallResponseSpec.resolveAdvisorChain builds a real StructuredOutputValidationAdvisor from the same JSON schema BeanOutputConverter uses to parse the response, and pushes it onto the advisor chain for that one call. - The schema/format instructions are baked into the user message once, up front, by entity() itself, before the advisor chain runs at all. A validation retry's only contribution is one appended line: "Output JSON validation failed because of: <the real schema-validator error>" -- each retry re-augments the ORIGINAL request, not the previous attempt's, so corrections never stack. - Default maxRepeatAttempts is 3 (4 total attempts); default advisorOrder is 2147481647, near Ordered.LOWEST_PRECEDENCE. - Exhausting every retry does NOT throw -- adviseCall's loop just returns the last (still invalid) response to the caller. Plain entity() with no validation, by contrast, throws immediately on the same bad JSON, since BeanOutputConverter.convert() is a separate Jackson deserialization step with no retry loop of its own. Both behaviors are captured from real runs (output/02, output/06). - Spring AI 2.0's JSON stack is Jackson 3 (tools.jackson.databind), not classic com.fasterxml.jackson -- visible directly in every one of this advisor's constructor and field signatures. Companion module for "Structured Output in Spring AI 2.0: Records, JSON Schema and Self-Correcting Responses" on ankurm.com. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01FtpJvZfg4nvLvtzgJTDWpB
52 lines
3.5 KiB
Markdown
52 lines
3.5 KiB
Markdown
# structured-output
|
|
|
|
Companion code for [Structured Output in Spring AI 2.0: Records, JSON Schema and Self-Correcting Responses](https://ankurm.com/spring-ai-2-0-structured-output/), part of the [Spring AI series](../README.md) on ankurm.com.
|
|
|
|
Every test drives the real `ChatClient.entity()` machinery and the real `StructuredOutputValidationAdvisor` against a hand-written `ScriptedChatModel` (see [`support/ScriptedChatModel.java`](src/test/java/com/ankurm/structuredoutput/support/ScriptedChatModel.java)) that queues pre-programmed responses instead of calling a live provider. No test in this module calls a real LLM.
|
|
|
|
## Versions
|
|
|
|
| Component | Version |
|
|
|---|---|
|
|
| Spring Boot | 4.1.1 |
|
|
| Spring AI | 2.0.1 |
|
|
| Java | 25 (LTS) |
|
|
|
|
`StructuredOutputValidationAdvisor` lives in `org.springframework.ai.chat.client.advisor`, inside the same `spring-ai-client-chat` artifact as `ToolCallingAdvisor` -- unlike the Tool Search Advisor pieces in this series' `tool-calling` module, it needs no separate Maven Central artifact or version pin.
|
|
|
|
## Quickstart
|
|
|
|
```bash
|
|
./scripts/run-all.sh
|
|
```
|
|
|
|
Runs the full test suite and regenerates every file under `output/`.
|
|
|
|
## What's here
|
|
|
|
| File | What it shows |
|
|
|---|---|
|
|
| [`domain/TicketTriage.java`](src/main/java/com/ankurm/structuredoutput/domain/TicketTriage.java) | The record used for the single-record examples; its `Priority` enum field is what gives the generated JSON schema a real `enum` constraint |
|
|
| [`domain/Priority.java`](src/main/java/com/ankurm/structuredoutput/domain/Priority.java) | A closed four-value enum |
|
|
| [`domain/ActionItem.java`](src/main/java/com/ankurm/structuredoutput/domain/ActionItem.java) | The record used for the `List<ActionItem>` example |
|
|
| [`EntityBindingRecordTest.java`](src/test/java/com/ankurm/structuredoutput/EntityBindingRecordTest.java) | `entity(Class)` on valid JSON, and the exception it throws with zero retries on invalid JSON |
|
|
| [`EntityBindingListTest.java`](src/test/java/com/ankurm/structuredoutput/EntityBindingListTest.java) | `entity(ParameterizedTypeReference<List<T>>)` |
|
|
| [`EntityBindingMapTest.java`](src/test/java/com/ankurm/structuredoutput/EntityBindingMapTest.java) | `entity(ParameterizedTypeReference<Map<String, Object>>)` |
|
|
| [`ValidationAdvisorRetriesOnceThenSucceedsTest.java`](src/test/java/com/ankurm/structuredoutput/ValidationAdvisorRetriesOnceThenSucceedsTest.java) | `entity(Class, spec -> spec.validateSchema())`: one bad response, one corrective retry, one successful mapping |
|
|
| [`ValidationAdvisorExhaustsRetriesTest.java`](src/test/java/com/ankurm/structuredoutput/ValidationAdvisorExhaustsRetriesTest.java) | `StructuredOutputValidationAdvisor` wired directly with `maxRepeatAttempts(2)`; every attempt fails and the advisor returns the last invalid response without throwing |
|
|
|
|
## Output files
|
|
|
|
| File | Captured from |
|
|
|---|---|
|
|
| `output/01-entity-record-valid.txt` | `EntityBindingRecordTest.mapsValidJsonToARecord` |
|
|
| `output/02-entity-record-invalid-no-retry.txt` | `EntityBindingRecordTest.plainEntityThrowsImmediatelyOnAnInvalidEnumValue_noRetry` |
|
|
| `output/03-entity-list-of-records.txt` | `EntityBindingListTest` |
|
|
| `output/04-entity-map.txt` | `EntityBindingMapTest` |
|
|
| `output/05-validation-advisor-retry-then-success.txt` | `ValidationAdvisorRetriesOnceThenSucceedsTest` |
|
|
| `output/06-validation-advisor-exhausts-retries.txt` | `ValidationAdvisorExhaustsRetriesTest` |
|
|
|
|
## Requirements
|
|
|
|
JDK 25, Maven. No API key needed -- `application.yml` supplies a placeholder that the autoconfigured `OpenAiChatModel` bean would use, but no test ever constructs that bean.
|