Files
Claude 7fff4ddb99 Add structured-output module: entity() mapping to records/lists/maps, StructuredOutputValidationAdvisor retries
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
2026-09-23 17:18:32 +00:00

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.