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
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
model call count: 1
|
||||
|
||||
mapped record: TicketTriage[category=billing, priority=HIGH, requiresEscalation=true, suggestedActions=[Refund the duplicate charge, Reply within 4 hours]]
|
||||
|
||||
JSON schema BeanOutputConverter generated for TicketTriage:
|
||||
{
|
||||
"$schema" : "https://json-schema.org/draft/2020-12/schema",
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"category" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"priority" : {
|
||||
"type" : "string",
|
||||
"enum" : [ "LOW", "MEDIUM", "HIGH", "CRITICAL" ]
|
||||
},
|
||||
"requiresEscalation" : {
|
||||
"type" : "boolean"
|
||||
},
|
||||
"suggestedActions" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"type" : "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required" : [ "category", "priority", "requiresEscalation", "suggestedActions" ],
|
||||
"additionalProperties" : false
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
model call count: 1
|
||||
|
||||
exception thrown to the caller (plain entity() never retries):
|
||||
tools.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `com.ankurm.structuredoutput.domain.Priority` from String "URGENT": not one of the values accepted for Enum class: [HIGH, LOW, MEDIUM, CRITICAL]
|
||||
at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); byte offset: #UNKNOWN] (through reference chain: com.ankurm.structuredoutput.domain.TicketTriage["priority"])
|
||||
@@ -0,0 +1,6 @@
|
||||
model call count: 1
|
||||
|
||||
mapped list (3 items):
|
||||
ActionItem[owner=Priya, task=Send the revised contract, dueDate=2026-09-25]
|
||||
ActionItem[owner=Marcus, task=Confirm the vendor's SLA numbers, dueDate=2026-09-26]
|
||||
ActionItem[owner=Priya, task=Book the kickoff call, dueDate=2026-09-24]
|
||||
@@ -0,0 +1,3 @@
|
||||
model call count: 1
|
||||
|
||||
mapped map: {darkModeEnabled=true, maxUploadSizeMb=25, betaFeatures=[new-dashboard, ai-search]}
|
||||
@@ -0,0 +1,74 @@
|
||||
model call count: 2
|
||||
|
||||
attempt 1 -- model sent priority "URGENT", not one of Priority's four enum values
|
||||
|
||||
first prompt's user message (entity() already bakes the schema in, before any retry):
|
||||
Customer was charged twice for the same order and wants a refund today.
|
||||
Your response should be in JSON format.
|
||||
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
|
||||
Do not include markdown code blocks in your response.
|
||||
Remove the ```json markdown from the output.
|
||||
Here is the JSON Schema instance your output must adhere to:
|
||||
```{
|
||||
"$schema" : "https://json-schema.org/draft/2020-12/schema",
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"category" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"priority" : {
|
||||
"type" : "string",
|
||||
"enum" : [ "LOW", "MEDIUM", "HIGH", "CRITICAL" ]
|
||||
},
|
||||
"requiresEscalation" : {
|
||||
"type" : "boolean"
|
||||
},
|
||||
"suggestedActions" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"type" : "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required" : [ "category", "priority", "requiresEscalation", "suggestedActions" ],
|
||||
"additionalProperties" : false
|
||||
}```
|
||||
|
||||
|
||||
second prompt's user message (the advisor's own contribution is the one appended line):
|
||||
Customer was charged twice for the same order and wants a refund today.
|
||||
Output JSON validation failed because of: does not have a value in the enumeration ["LOW", "MEDIUM", "HIGH", "CRITICAL"]
|
||||
Your response should be in JSON format.
|
||||
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
|
||||
Do not include markdown code blocks in your response.
|
||||
Remove the ```json markdown from the output.
|
||||
Here is the JSON Schema instance your output must adhere to:
|
||||
```{
|
||||
"$schema" : "https://json-schema.org/draft/2020-12/schema",
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"category" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"priority" : {
|
||||
"type" : "string",
|
||||
"enum" : [ "LOW", "MEDIUM", "HIGH", "CRITICAL" ]
|
||||
},
|
||||
"requiresEscalation" : {
|
||||
"type" : "boolean"
|
||||
},
|
||||
"suggestedActions" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"type" : "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required" : [ "category", "priority", "requiresEscalation", "suggestedActions" ],
|
||||
"additionalProperties" : false
|
||||
}```
|
||||
|
||||
|
||||
attempt 2 -- model sent priority "HIGH", validation passed
|
||||
|
||||
final mapped record: TicketTriage[category=billing, priority=HIGH, requiresEscalation=true, suggestedActions=[Refund the duplicate charge]]
|
||||
@@ -0,0 +1,7 @@
|
||||
model call count: 3 (1 initial attempt + maxRepeatAttempts(2) retries)
|
||||
|
||||
every attempt returned the same invalid "URGENT" priority value
|
||||
|
||||
raw content returned to the caller (still invalid -- the advisor does not throw):
|
||||
{"category":"billing","priority":"URGENT","requiresEscalation":true,
|
||||
"suggestedActions":["Refund the duplicate charge"]}
|
||||
Reference in New Issue
Block a user