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:
+13
@@ -0,0 +1,13 @@
|
||||
package com.ankurm.structuredoutput;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class StructuredOutputApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(StructuredOutputApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.ankurm.structuredoutput.config;
|
||||
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Production wiring: one plain {@code ChatClient} bean with no structured-output advisor
|
||||
* registered by default. Structured output in this module is opted into per call, through
|
||||
* {@code ChatClient.CallResponseSpec.entity(...)} -- see
|
||||
* {@code EntityBindingRecordTest}, {@code EntityBindingListTest}, and
|
||||
* {@code EntityBindingMapTest} for the plain path, and
|
||||
* {@code ValidationAdvisorRetriesOnceThenSucceedsTest} for the self-correcting one.
|
||||
*/
|
||||
@Configuration
|
||||
public class ChatClientConfig {
|
||||
|
||||
@Bean
|
||||
ChatClient chatClient(ChatModel chatModel) {
|
||||
return ChatClient.builder(chatModel).build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.structuredoutput.domain;
|
||||
|
||||
/**
|
||||
* One row of a meeting's action items. The target type for the {@code List<ActionItem>}
|
||||
* examples in this module -- mapping a whole list of records out of one model response, not just
|
||||
* a single one.
|
||||
*/
|
||||
public record ActionItem(String owner, String task, String dueDate) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.ankurm.structuredoutput.domain;
|
||||
|
||||
/**
|
||||
* A closed set of triage priorities. Because this is a Java enum, the JSON schema
|
||||
* {@code BeanOutputConverter} generates for {@link TicketTriage} constrains this field to
|
||||
* exactly these four values with a real {@code "enum": [...]} entry -- not a comment telling the
|
||||
* model what's allowed, a schema constraint the validator actually checks a response against.
|
||||
*/
|
||||
public enum Priority {
|
||||
|
||||
LOW, MEDIUM, HIGH, CRITICAL
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ankurm.structuredoutput.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What a support ticket's free-text description gets mapped to. The target type for the
|
||||
* "single record" examples in this module -- and, because {@link Priority} is a closed enum, the
|
||||
* type whose generated JSON schema is strict enough to genuinely reject a bad response instead of
|
||||
* accepting whatever string the model feels like sending.
|
||||
*/
|
||||
public record TicketTriage(String category, Priority priority, boolean requiresEscalation,
|
||||
List<String> suggestedActions) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
spring:
|
||||
ai:
|
||||
openai:
|
||||
# Every test in this module drives a hand-written ScriptedChatModel (see
|
||||
# src/test/java/com/ankurm/structuredoutput/support/ScriptedChatModel.java) -- the
|
||||
# autoconfigured OpenAiChatModel bean this key would back is never constructed in a test.
|
||||
api-key: ${OPENAI_API_KEY:demo-key-not-used-by-tests}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.ankurm.structuredoutput;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.ankurm.structuredoutput.domain.ActionItem;
|
||||
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;
|
||||
|
||||
/**
|
||||
* {@code entity()} isn't limited to a single record -- a {@link ParameterizedTypeReference}
|
||||
* maps a whole JSON array to a {@code List<ActionItem>} in one call, no manual iteration over a
|
||||
* parsed {@code JsonNode}.
|
||||
*/
|
||||
class EntityBindingListTest {
|
||||
|
||||
@Test
|
||||
void mapsAJsonArrayToAListOfRecords() {
|
||||
ScriptedChatModel model = ScriptedChatModel.builder()
|
||||
.thenRespond("""
|
||||
[
|
||||
{"owner":"Priya","task":"Send the revised contract","dueDate":"2026-09-25"},
|
||||
{"owner":"Marcus","task":"Confirm the vendor's SLA numbers","dueDate":"2026-09-26"},
|
||||
{"owner":"Priya","task":"Book the kickoff call","dueDate":"2026-09-24"}
|
||||
]
|
||||
""")
|
||||
.build();
|
||||
ChatClient client = ChatClient.builder(model).build();
|
||||
|
||||
List<ActionItem> actionItems = client.prompt()
|
||||
.user("Extract the action items from this meeting note: Priya will send the revised "
|
||||
+ "contract by Friday. Marcus needs to confirm the vendor's SLA numbers. Priya "
|
||||
+ "should also book the kickoff call for tomorrow.")
|
||||
.call()
|
||||
.entity(new ParameterizedTypeReference<List<ActionItem>>() {
|
||||
});
|
||||
|
||||
assertThat(actionItems).hasSize(3);
|
||||
assertThat(actionItems).extracting(ActionItem::owner).containsExactly("Priya", "Marcus", "Priya");
|
||||
assertThat(model.callCount()).isEqualTo(1);
|
||||
|
||||
Transcript.write("03-entity-list-of-records",
|
||||
"model call count: " + model.callCount() + "\n\n" + "mapped list (" + actionItems.size()
|
||||
+ " items):\n" + actionItems.stream().map(Object::toString).reduce("", (a, b) -> a + " " + b + "\n"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.ankurm.structuredoutput;
|
||||
|
||||
import com.ankurm.structuredoutput.domain.Priority;
|
||||
import com.ankurm.structuredoutput.domain.TicketTriage;
|
||||
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.ai.converter.BeanOutputConverter;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The plain path: {@code ChatClient.CallResponseSpec.entity(Class)}, no validation, no retries.
|
||||
* When the model's JSON matches the record's shape, this is all mapping a response to a record
|
||||
* takes. When it doesn't -- one bad enum value here -- {@code entity()} throws immediately,
|
||||
* which is the motivating problem the rest of this module's tests solve.
|
||||
*/
|
||||
class EntityBindingRecordTest {
|
||||
|
||||
@Test
|
||||
void mapsValidJsonToARecord() {
|
||||
ScriptedChatModel model = ScriptedChatModel.builder()
|
||||
.thenRespond("""
|
||||
{"category":"billing","priority":"HIGH","requiresEscalation":true,
|
||||
"suggestedActions":["Refund the duplicate charge","Reply within 4 hours"]}
|
||||
""")
|
||||
.build();
|
||||
ChatClient client = ChatClient.builder(model).build();
|
||||
|
||||
TicketTriage triage = client.prompt()
|
||||
.user("Customer was charged twice for the same order and wants a refund today.")
|
||||
.call()
|
||||
.entity(TicketTriage.class);
|
||||
|
||||
assertThat(triage.category()).isEqualTo("billing");
|
||||
assertThat(triage.priority()).isEqualTo(Priority.HIGH);
|
||||
assertThat(triage.requiresEscalation()).isTrue();
|
||||
assertThat(triage.suggestedActions()).containsExactly("Refund the duplicate charge", "Reply within 4 hours");
|
||||
assertThat(model.callCount()).isEqualTo(1);
|
||||
|
||||
BeanOutputConverter<TicketTriage> converter = new BeanOutputConverter<>(TicketTriage.class);
|
||||
Transcript.write("01-entity-record-valid",
|
||||
"model call count: " + model.callCount() + "\n\n" + "mapped record: " + triage + "\n\n"
|
||||
+ "JSON schema BeanOutputConverter generated for TicketTriage:\n" + converter.getJsonSchema());
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainEntityThrowsImmediatelyOnAnInvalidEnumValue_noRetry() {
|
||||
// "URGENT" is not one of Priority's four constants -- Jackson rejects it during
|
||||
// deserialization, and plain entity() has no retry loop to catch that.
|
||||
ScriptedChatModel model = ScriptedChatModel.builder()
|
||||
.thenRespond("""
|
||||
{"category":"billing","priority":"URGENT","requiresEscalation":true,
|
||||
"suggestedActions":["Refund the duplicate charge"]}
|
||||
""")
|
||||
.build();
|
||||
ChatClient client = ChatClient.builder(model).build();
|
||||
|
||||
Throwable thrown = org.assertj.core.api.Assertions
|
||||
.catchThrowable(() -> client.prompt().user("Customer was charged twice.").call().entity(TicketTriage.class));
|
||||
|
||||
assertThat(thrown).isNotNull();
|
||||
assertThat(model.callCount()).isEqualTo(1);
|
||||
|
||||
Transcript.write("02-entity-record-invalid-no-retry", "model call count: " + model.callCount() + "\n\n"
|
||||
+ "exception thrown to the caller (plain entity() never retries):\n" + thrown.getClass().getName()
|
||||
+ ": " + thrown.getMessage());
|
||||
}
|
||||
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.ankurm.structuredoutput;
|
||||
|
||||
import com.ankurm.structuredoutput.domain.TicketTriage;
|
||||
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.ai.chat.client.advisor.StructuredOutputValidationAdvisor;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* What the defaults do not do: when every attempt keeps failing validation, disassembling
|
||||
* {@code StructuredOutputValidationAdvisor.adviseCall} shows the loop just runs out of attempts
|
||||
* and returns the caller the last (still-invalid) response -- it never throws. This module wires
|
||||
* the advisor directly with {@code .advisors(...)} instead of the {@code entity()} convenience,
|
||||
* which is also how you'd reuse one validating advisor across many calls instead of opting in
|
||||
* per call, and sets {@code maxRepeatAttempts(2)} explicitly so the test doesn't depend on the
|
||||
* advisor's own default of 3.
|
||||
*/
|
||||
class ValidationAdvisorExhaustsRetriesTest {
|
||||
|
||||
@Test
|
||||
void exhaustsAllRetriesAndReturnsTheLastInvalidResponseWithoutThrowing() {
|
||||
String stillInvalid = """
|
||||
{"category":"billing","priority":"URGENT","requiresEscalation":true,
|
||||
"suggestedActions":["Refund the duplicate charge"]}
|
||||
""";
|
||||
// maxRepeatAttempts(2) means 1 initial attempt + 2 retries = 3 total calls -- three
|
||||
// scripted responses, all still invalid, so every retry is genuinely exhausted.
|
||||
ScriptedChatModel model = ScriptedChatModel.builder()
|
||||
.thenRespond(stillInvalid)
|
||||
.thenRespond(stillInvalid)
|
||||
.thenRespond(stillInvalid)
|
||||
.build();
|
||||
StructuredOutputValidationAdvisor advisor = StructuredOutputValidationAdvisor.builder()
|
||||
.outputType(TicketTriage.class)
|
||||
.maxRepeatAttempts(2)
|
||||
.build();
|
||||
ChatClient client = ChatClient.builder(model).build();
|
||||
|
||||
// content(), not entity() -- this isolates the advisor's own behavior from
|
||||
// BeanOutputConverter.convert(), which is a separate step that would throw on this same
|
||||
// bad JSON if it were asked to parse it into a TicketTriage.
|
||||
String rawContent = client.prompt()
|
||||
.user("Customer was charged twice for the same order and wants a refund today.")
|
||||
.advisors(advisor)
|
||||
.call()
|
||||
.content();
|
||||
|
||||
assertThat(model.callCount()).isEqualTo(3);
|
||||
assertThat(rawContent).contains("\"priority\":\"URGENT\"");
|
||||
|
||||
Transcript.write("06-validation-advisor-exhausts-retries",
|
||||
"model call count: " + model.callCount() + " (1 initial attempt + maxRepeatAttempts(2) retries)\n\n"
|
||||
+ "every attempt returned the same invalid \"URGENT\" priority value\n\n"
|
||||
+ "raw content returned to the caller (still invalid -- the advisor does not throw):\n"
|
||||
+ rawContent);
|
||||
}
|
||||
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.ankurm.structuredoutput;
|
||||
|
||||
import com.ankurm.structuredoutput.domain.Priority;
|
||||
import com.ankurm.structuredoutput.domain.TicketTriage;
|
||||
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.ai.chat.prompt.Prompt;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The self-correcting path: {@code entity(Class, spec -> spec.validateSchema())}. Disassembling
|
||||
* {@code DefaultChatClient$DefaultCallResponseSpec.resolveAdvisorChain} shows exactly what that
|
||||
* one flag does -- it builds a real {@code StructuredOutputValidationAdvisor} from the same JSON
|
||||
* schema {@code BeanOutputConverter} would use to parse the response, and pushes it onto the
|
||||
* advisor chain for this call only. The model's first response here has the same bad "URGENT"
|
||||
* enum value {@code EntityBindingRecordTest} showed blowing up plain {@code entity()}; this time
|
||||
* the advisor catches it, tells the model what was wrong, and the second response succeeds.
|
||||
*/
|
||||
class ValidationAdvisorRetriesOnceThenSucceedsTest {
|
||||
|
||||
@Test
|
||||
void retriesOnceAfterASchemaValidationFailureThenSucceeds() {
|
||||
ScriptedChatModel model = ScriptedChatModel.builder()
|
||||
.thenRespond("""
|
||||
{"category":"billing","priority":"URGENT","requiresEscalation":true,
|
||||
"suggestedActions":["Refund the duplicate charge"]}
|
||||
""")
|
||||
.thenRespond("""
|
||||
{"category":"billing","priority":"HIGH","requiresEscalation":true,
|
||||
"suggestedActions":["Refund the duplicate charge"]}
|
||||
""")
|
||||
.build();
|
||||
ChatClient client = ChatClient.builder(model).build();
|
||||
|
||||
TicketTriage triage = client.prompt()
|
||||
.user("Customer was charged twice for the same order and wants a refund today.")
|
||||
.call()
|
||||
.entity(TicketTriage.class, spec -> spec.validateSchema());
|
||||
|
||||
assertThat(triage.priority()).isEqualTo(Priority.HIGH);
|
||||
assertThat(model.callCount()).isEqualTo(2);
|
||||
|
||||
Prompt firstPrompt = model.capturedPrompts().get(0);
|
||||
Prompt secondPrompt = model.capturedPrompts().get(1);
|
||||
String firstUserText = firstPrompt.getUserMessage().getText();
|
||||
String secondUserText = secondPrompt.getUserMessage().getText();
|
||||
|
||||
// The schema/format instructions are already in the FIRST prompt -- entity() bakes them
|
||||
// into the user message before the advisor chain ever runs. The advisor's own
|
||||
// contribution on a retry is exactly one appended line.
|
||||
assertThat(firstUserText).contains("Here is the JSON Schema instance your output must adhere to:");
|
||||
assertThat(secondUserText).contains("Output JSON validation failed because of:");
|
||||
assertThat(secondUserText).contains("Customer was charged twice for the same order and wants a refund today.");
|
||||
|
||||
Transcript.write("05-validation-advisor-retry-then-success",
|
||||
"model call count: " + model.callCount() + "\n\n"
|
||||
+ "attempt 1 -- model sent priority \"URGENT\", not one of Priority's four enum values\n\n"
|
||||
+ "first prompt's user message (entity() already bakes the schema in, before any retry):\n"
|
||||
+ firstUserText + "\n\n"
|
||||
+ "second prompt's user message (the advisor's own contribution is the one appended line):\n"
|
||||
+ secondUserText + "\n\n" + "attempt 2 -- model sent priority \"HIGH\", validation passed\n\n"
|
||||
+ "final mapped record: " + triage);
|
||||
}
|
||||
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.ankurm.structuredoutput.support;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.model.Generation;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
|
||||
/**
|
||||
* A hand-written {@link ChatModel} that returns a queue of pre-scripted responses instead of
|
||||
* calling a real provider. {@code ChatModel} has exactly one abstract method,
|
||||
* {@code call(Prompt)} -- confirmed with {@code javap} on spring-ai-model-2.0.1.jar, the same
|
||||
* finding this series' tool-calling article made -- so this class is a fully legitimate
|
||||
* {@code ChatModel} from {@code StructuredOutputValidationAdvisor}'s point of view, not a mock
|
||||
* standing in for one. Every retry this module's tests observe is the real advisor calling this
|
||||
* model again, not a simulated retry.
|
||||
*/
|
||||
public class ScriptedChatModel implements ChatModel {
|
||||
|
||||
private final Queue<ChatResponse> script;
|
||||
|
||||
private final List<Prompt> capturedPrompts = new CopyOnWriteArrayList<>();
|
||||
|
||||
private ScriptedChatModel(Deque<ChatResponse> script) {
|
||||
this.script = script;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatResponse call(Prompt prompt) {
|
||||
this.capturedPrompts.add(prompt);
|
||||
ChatResponse next = this.script.poll();
|
||||
if (next == null) {
|
||||
throw new IllegalStateException("ScriptedChatModel ran out of queued responses after "
|
||||
+ this.capturedPrompts.size() + " calls. Prompts so far: " + this.capturedPrompts);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatOptions getOptions() {
|
||||
return ChatOptions.builder().build();
|
||||
}
|
||||
|
||||
public List<Prompt> capturedPrompts() {
|
||||
return List.copyOf(this.capturedPrompts);
|
||||
}
|
||||
|
||||
public int callCount() {
|
||||
return this.capturedPrompts.size();
|
||||
}
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
|
||||
private final Deque<ChatResponse> script = new ArrayDeque<>();
|
||||
|
||||
public Builder thenRespond(String text) {
|
||||
this.script.add(new ChatResponse(List.of(new Generation(new AssistantMessage(text)))));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ScriptedChatModel build() {
|
||||
return new ScriptedChatModel(new ArrayDeque<>(this.script));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.ankurm.structuredoutput.support;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Writes a test's captured, real output to {@code output/<name>.txt} and echoes it to stdout.
|
||||
* Every console block quoted in the companion article comes from a file this class wrote --
|
||||
* nothing in the article is retyped or tidied up by hand.
|
||||
*/
|
||||
public final class Transcript {
|
||||
|
||||
private Transcript() {
|
||||
}
|
||||
|
||||
public static void write(String name, String content) {
|
||||
try {
|
||||
Path dir = Path.of("output");
|
||||
Files.createDirectories(dir);
|
||||
Path file = dir.resolve(name + ".txt");
|
||||
Files.writeString(file, content, StandardCharsets.UTF_8);
|
||||
System.out.println("--- " + name + " ---");
|
||||
System.out.println(content);
|
||||
}
|
||||
catch (IOException ex) {
|
||||
throw new UncheckedIOException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user