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
45 lines
1.6 KiB
Java
45 lines
1.6 KiB
Java
package com.ankurm.structuredoutput;
|
|
|
|
import java.util.Map;
|
|
|
|
import com.ankurm.structuredoutput.support.ScriptedChatModel;
|
|
import com.ankurm.structuredoutput.support.Transcript;
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
import org.springframework.ai.chat.client.ChatClient;
|
|
import org.springframework.core.ParameterizedTypeReference;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
|
|
/**
|
|
* Sometimes there's no record worth declaring -- a one-off extraction, a shape that varies call
|
|
* to call. {@code entity()} maps straight to a {@code Map<String, Object>} for that case, the
|
|
* same converter machinery as the record and list cases, just with a looser target type.
|
|
*/
|
|
class EntityBindingMapTest {
|
|
|
|
@Test
|
|
void mapsAJsonObjectToAMapWhenNoRecordIsWorthDeclaring() {
|
|
ScriptedChatModel model = ScriptedChatModel.builder()
|
|
.thenRespond("""
|
|
{"darkModeEnabled":true,"maxUploadSizeMb":25,"betaFeatures":["new-dashboard","ai-search"]}
|
|
""")
|
|
.build();
|
|
ChatClient client = ChatClient.builder(model).build();
|
|
|
|
Map<String, Object> flags = client.prompt()
|
|
.user("Extract the feature flags mentioned here: dark mode is on, max upload size is 25MB, "
|
|
+ "and the beta features enabled are new-dashboard and ai-search.")
|
|
.call()
|
|
.entity(new ParameterizedTypeReference<Map<String, Object>>() {
|
|
});
|
|
|
|
assertThat(flags).containsEntry("darkModeEnabled", true).containsEntry("maxUploadSizeMb", 25);
|
|
assertThat(model.callCount()).isEqualTo(1);
|
|
|
|
Transcript.write("04-entity-map",
|
|
"model call count: " + model.callCount() + "\n\n" + "mapped map: " + flags);
|
|
}
|
|
|
|
}
|