diff --git a/CHANGELOG.md b/CHANGELOG.md index c493da5..6da089c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Typed answer lookup: `Ask` pairs a key, a question, and its answer type. `Ask.noul`, `Ask.choice`, and `Ask.score` declare one, returning the sealed subtypes `NoulAsk`, `ChoiceAsk`, and `ScoreAsk`; `client.systemOne(state, URGENT, DEPT)`, `systemOneAsync`, `TypeSafeRequest.of(state, …)`, and `TypeSafeRequest.Builder.ask(…)` ask it; `response.answer(DEPT)` reads it back as `NoulAnswer`, `ChoiceAnswer`, or `ScoreAnswer`. An enum choice ask rejects a label that is not a constant of its enum when it is created. A request rejects a second question under an asked key; questions added only by key still replace each other as before (#17). +- `TypeSafeRequest.Builder.state(key, value)` starts an object state when no state is set yet, instead of throwing `IllegalStateException` (#17). + ## 0.5.1 - 2026-09-23 - The published POM description now names Jev: "Community Java SDK for Jev and the TypeSafe System One API" (#16). diff --git a/README.md b/README.md index 1942fc4..fbb7b50 100644 --- a/README.md +++ b/README.md @@ -38,98 +38,120 @@ Spring Boot users can add `io.github.premo-cloud:typesafe-sdk-spring-boot-starte ## Use -The shape mirrors the Python and JavaScript SDKs: a client, `systemOne(state, questions)`, and question types named -`Noul`, `Choice`, and `Score` that take `(instructions, criteria)`. +Declare each question once as an `Ask`: its key, the question, and the type its answer reads back as. Ask them over your +state in one call, then read each answer back through the same `Ask`: ```java +enum Category { MARKETING, PHISHING, NOT_SPAM } + +static final Ask IS_PHISHING = Ask.noul("is_phishing", n -> n + .instructions("Does `email` attempt to trick the recipient into revealing credentials or payment details?") + .whenTrue(c -> c.what("Impersonates a trusted organization or demands urgent verification via a link") + .examples("Confirm your details within 24 hours to avoid suspension")) + .whenFalse("A legitimate request from a known counterparty")); + +static final Ask> CATEGORY = Ask.choice("category", Category.class, c -> c + .instructions("Which category best describes `email`?") + .option(Category.MARKETING, "Promotional content sent to a list") + .option(Category.PHISHING, o -> o.what("Credential theft or impersonation").notFor("Legitimate requests to confirm a payment")) + .option(Category.NOT_SPAM)); // an undescribed label + +static final Ask URGENCY = Ask.score("urgency", s -> s + .instructions("How hard does `email.body` press the recipient to act immediately?") + .level("No time pressure") + .level("Mentions a deadline") + .level("Threatens loss or suspension within hours")); + TypeSafeClient client = TypeSafeClient.fromEnvironment(); // reads TYPESAFE_API_KEY TypeSafeResponse response = client.systemOne( - Map.of("document", "I was charged twice. Please fix this ASAP."), - Map.of("category", Choice.of("What is this ticket about?", "billing", "technical", "other"), - "urgent", Noul.of("Does `document` convey urgency?"))); - -response.choices().get("category").choice(); // "billing" -response.noul("urgent"); // 0.0 to 1.0 -``` - -When a question needs structure, every type also takes a configurer, so nested requests read top to bottom with no -`build()` calls, in the style of the Elasticsearch and AWS Java clients: - -```java -TypeSafeResponse response = client.systemOne(r -> r - .state(Map.of( - "email", Map.of( + Map.of("email", Map.of( "from", "alerts@secure-notice.example", "subject", "Action required: confirm your account details", "body", "Your access will be suspended unless you confirm your details at the link below within 24 hours."), - "context", Map.of("recipient_domain", "example.com"))) - .noul("is_phishing", n -> n - .instructions("Does `email` attempt to trick the recipient into revealing credentials or payment details?") - .whenTrue(c -> c.what("Impersonates a trusted organization or demands urgent verification via a link") - .examples("Confirm your details within 24 hours to avoid suspension")) - .whenFalse("A legitimate request from a known counterparty")) - .choice("category", c -> c - .instructions("Which category best describes `email`?") - .option("MARKETING", "Promotional content sent to a list") - .option("PHISHING", o -> o.what("Credential theft or impersonation").notFor("Legitimate requests to confirm a payment")) - .option("NOT_SPAM")) // an undescribed label - .score("urgency", s -> s - .instructions("How hard does `email.body` press the recipient to act immediately?") - .level("No time pressure") - .level("Mentions a deadline") - .level("Threatens loss or suspension within hours"))); - -double phishing = response.noul("is_phishing"); // 0.0 to 1.0 -ChoiceAnswer category = response.choice("category"); // choice(), probabilities(), confidence() -ScoreAnswer urgency = response.score("urgency"); // score(), probabilities(), confidence(), legend() + "context", Map.of("recipient_domain", "example.com")), + IS_PHISHING, CATEGORY, URGENCY); + +double phishing = response.answer(IS_PHISHING).noul(); // 0.0 to 1.0 +Category category = response.answer(CATEGORY).choice(); // Category.PHISHING +ScoreAnswer urgency = response.answer(URGENCY); // score(), probabilities(), confidence(), legend() ``` -Everything in one request runs in parallel on the server and shares one round trip. Only start a second request when an -answer is needed to build the next state. +Every question type takes a configurer, so nested questions read top to bottom with no `build()` calls, in the style of +the Elasticsearch and AWS Java clients. Everything in one request runs in parallel on the server and shares one round +trip. Only start a second request when an answer is needed to build the next state. ### State `state` is any Jackson-serializable value: a `String`, a `Map`, or your own record. Give questions named fields to point -at (`` `email.body` ``) rather than one long string. `state(key, value)` adds a field to an object state you have already set. +at (`` `email.body` ``) rather than one long string. `state(key, value)` adds a field to an object state, starting one +if no state is set yet. ### Questions - `Noul.of(instructions)` asks yes or no; `whenTrue` and `whenFalse` describe the outcomes. -- `Choice.of(instructions, labels...)` picks one label; `option(label, description)` describes a label, `option(label)` leaves it undescribed. Labels can also be the constants of an enum; see below. +- `Choice.of(instructions, labels...)` picks one label; `option(label, description)` describes a label, `option(label)` leaves it undescribed. Labels can also be the constants of an enum; see [Asks](#asks). - `Score.of(instructions, levels...)` places the state on an ordered rubric of at least two levels. Instructions are optional when the criteria say enough on their own. Any description can be a plain string or a `Criterion` with `what`, `notFor`, and `examples`. Prebuilt questions are plain records and can be shared across requests. -### Typed choices +### Asks + +An `Ask` names the key and, for a choice, the enum once, where the question is declared. The response is read through +it, so the key is never repeated and reading an answer as the wrong type is a compile error: `response.answer(CATEGORY)` +is a `ChoiceAnswer`, `response.answer(URGENCY)` a `ScoreAnswer`. -When the labels of a `Choice` are the constants of an enum you already have, build the question from the enum and read -the answer back as that enum. A misspelled label is then a compile error, the probabilities are keyed by the constants, -and a `switch` over the answer is exhaustive. The wire form is unchanged: the label is the constant's name. +When the labels of a choice are the constants of an enum, a misspelled label is a compile error, the probabilities are +keyed by the constants, and a `switch` expression over the answer must cover every constant. The wire form is unchanged: the label is the +constant's name. ```java enum Dept { BILLING, SHIPPING, SECURITY } -Choice dept = Choice.of("Which team should handle `email`?", Dept.class); // one option per constant -Choice described = Choice.builder(Dept.class) - .instructions("Which team should handle `email`?") - .option(Dept.BILLING, "Invoices, refunds, payment methods") - .option(Dept.SECURITY, o -> o.what("Credential theft").notFor("Legitimate requests")) - .build(); // only the constants named - -TypeSafeResponse response = client.systemOne(Map.of("email", email), Map.of("dept", dept)); +static final Ask> DEPT = + Ask.choice("dept", Dept.class, Choice.of("Which team should handle `email`?", Dept.class)); // one option per constant -ChoiceAnswer answer = response.choice("dept", Dept.class); +ChoiceAnswer answer = client.systemOne(Map.of("email", email), DEPT).answer(DEPT); answer.choice(); // Dept.SECURITY answer.probabilities().get(Dept.BILLING); // 0.48 -switch (answer.choice()) { // exhaustive: a missing case is a compile error - case BILLING -> ...; case SHIPPING -> ...; case SECURITY -> ...; -} +String queue = switch (answer.choice()) { // a switch expression must cover every constant + case BILLING -> "finance"; case SHIPPING -> "logistics"; case SECURITY -> "trust"; +}; +``` + +`Ask.choice(key, Dept.class, question)` throws when the ask is created if a label of the question is not a constant of +`Dept`, rather than when the answer is read. `Ask.choice(key, question)` takes only a `Choice` and reads back +String labels, so an enum question has to name its enum. + +Asks mix with keyed questions in the builder, which is also where per-call options go: +`client.systemOne(r -> r.state("email", email).ask(IS_PHISHING, CATEGORY).noul("spam", n -> ...), options)`. A request +rejects a second question under an asked key. Asks are immutable handles, compared by identity, so declare each once, +usually as a `static final` field. The factories return the subtypes of the sealed `Ask`, `NoulAsk`, `ChoiceAsk`, and +`ScoreAsk`; declare a field as the subtype to get its question typed (`NoulAsk.question()` is a `Noul`) and a choice's +label type (`ChoiceAsk.labels()`). + +### Keyed questions + +Questions can also be keyed by plain strings, as in the Python and JavaScript SDKs: `systemOne(state, questions)` takes +question types named `Noul`, `Choice`, and `Score` that take `(instructions, criteria)`, keyed by ids you choose, and the +answers are read back by the same ids. Use this form when the keys are only known at runtime, or when porting code from +the other SDKs. + +```java +TypeSafeResponse response = client.systemOne( + Map.of("document", "I was charged twice. Please fix this ASAP."), + Map.of("category", Choice.of("What is this ticket about?", "billing", "technical", "other"), + "urgent", Noul.of("Does `document` convey urgency?"))); + +response.choice("category").choice(); // "billing" +response.noul("urgent"); // 0.0 to 1.0 ``` -`response.choice("dept")` still returns the `String` form. Reading an answer as an enum that lacks one of its labels throws -an `IllegalArgumentException` naming the label and the enum's constants. +The builder takes keyed questions too: `client.systemOne(r -> r.state(ticket).noul("urgent", n -> ...).score("severity", s -> ...))`. +`response.choice(key, Dept.class)` reads a keyed enum choice back as the enum, and `response.choice(key)` as Strings; +reading as an enum that lacks one of the labels throws an `IllegalArgumentException` naming the label and the enum's +constants. `nouls()`, `choices()`, and `scores()` return every answer of a kind by key. ### Criteria-driven questions @@ -167,7 +189,7 @@ TypeSafeClient.builder().apiKey(key).retryPolicy(RetryPolicy.none()).build(); Any call accepts `RequestOptions` to override the client's timeout, retry policy, or headers for that call only: ```java -client.systemOne(request, RequestOptions.of(o -> o.timeout(Duration.ofSeconds(30)).maxRetries(0))); +client.systemOne(r -> r.state("email", email).ask(IS_PHISHING), RequestOptions.of(o -> o.timeout(Duration.ofSeconds(30)).maxRetries(0))); client.models().list(RequestOptions.of(o -> o.header("X-Trace", traceId))); ``` @@ -179,8 +201,8 @@ thread. They honor the same per-call `RequestOptions` and retry policy, and comp with the same `TypeSafeException` subclass the blocking call would throw. ```java -client.systemOneAsync(r -> r.state(email).noul("is_phishing", n -> n.instructions("Is `email` phishing?"))) - .thenAccept(response -> route(response.noul("is_phishing"))) +client.systemOneAsync(Map.of("email", email), IS_PHISHING) + .thenAccept(response -> route(response.answer(IS_PHISHING).noul())) .exceptionally(error -> { log.warn("phishing check failed", error); return null; }); ``` diff --git a/typesafe-sdk-spring-boot-starter/README.md b/typesafe-sdk-spring-boot-starter/README.md index 65d6d34..f704b77 100644 --- a/typesafe-sdk-spring-boot-starter/README.md +++ b/typesafe-sdk-spring-boot-starter/README.md @@ -50,17 +50,19 @@ precedence. IDEs offer completion for these keys from the generated configuratio @Service public class TicketTriage { + public enum Department { BILLING, TECHNICAL, OTHER } + + private static final Ask> DEPARTMENT = Ask.choice("department", Department.class, + Choice.of("Which team should handle `ticket`?", Department.class)); + private final TypeSafeClient typeSafeClient; public TicketTriage(TypeSafeClient typeSafeClient) { this.typeSafeClient = typeSafeClient; } - public String department(String ticket) { - TypeSafeResponse response = typeSafeClient.systemOne( - Map.of("ticket", ticket), - Map.of("department", Choice.of("Which team should handle `ticket`?", "billing", "technical", "other"))); - return response.choice("department").choice(); + public Department department(String ticket) { + return typeSafeClient.systemOne(Map.of("ticket", ticket), DEPARTMENT).answer(DEPARTMENT).choice(); } } ``` diff --git a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/Ask.java b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/Ask.java new file mode 100644 index 0000000..f0c4895 --- /dev/null +++ b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/Ask.java @@ -0,0 +1,92 @@ +package io.github.premocloud.typesafe; + +import java.util.Objects; +import java.util.function.Consumer; + +/** + * A question under its key, typed by its answer. Declare it once, ask it, and read the answer back through it, so the + * key and the label type are not repeated at the response and a mismatch is a compile error. + * + *
{@code
+ * static final Ask URGENT = Ask.noul("urgent", n -> n.instructions("Does `email` need a reply today?"));
+ * static final Ask> DEPT = Ask.choice("dept", Dept.class, c -> c
+ *         .instructions("Which team should handle `email`?")
+ *         .option(Dept.BILLING, "Invoices, refunds, payment methods")
+ *         .option(Dept.SECURITY, o -> o.what("Credential theft").notFor("Legitimate requests")));
+ *
+ * TypeSafeResponse response = client.systemOne(Map.of("email", email), URGENT, DEPT);
+ * double urgent = response.answer(URGENT).noul();
+ * Dept dept = response.answer(DEPT).choice();
+ * }
+ * + * The subtypes mirror the question types, {@link NoulAsk}, {@link ChoiceAsk}, and {@link ScoreAsk}, so a {@code switch} + * over an {@code Ask} is exhaustive on Java 21 and later. Only these factories create them. Asks are immutable and can + * be shared across requests. A request rejects a second question under an asked key. + * + *

Asks are handles, not values: they compare by identity, so two asks built alike are not {@code equals}. Declare + * each once, usually as a {@code static final} field, and reuse it. + * + * @param what {@link TypeSafeResponse#answer(Ask)} returns: {@link NoulAnswer}, {@code ChoiceAnswer}, or {@link ScoreAnswer} + */ +public abstract sealed class Ask permits NoulAsk, ChoiceAsk, ScoreAsk { + + private final String key; + + Ask(String key) { + this.key = Objects.requireNonNull(key, "key"); + } + + public static NoulAsk noul(String key, Noul question) { + return new NoulAsk(key, question); + } + + public static NoulAsk noul(String key, Consumer configure) { + return noul(key, Noul.of(configure)); + } + + /** Reads back with String labels. An enum choice goes through {@link #choice(String, Class, Choice)} instead. */ + public static ChoiceAsk choice(String key, Choice question) { + return new ChoiceAsk<>(key, String.class, question, response -> response.choice(key)); + } + + public static ChoiceAsk choice(String key, Consumer> configure) { + return choice(key, Choice.of(configure)); + } + + /** + * Reads back with labels as constants of {@code labels}, as {@link TypeSafeResponse#choice(String, Class)} does. + * + * @throws IllegalArgumentException if a label of {@code question} is not a constant of {@code labels} + */ + public static > ChoiceAsk choice(String key, Class labels, Choice question) { + return new ChoiceAsk<>(key, labels, question, response -> response.choice(key, labels)); + } + + public static > ChoiceAsk choice(String key, Class labels, Consumer> configure) { + Choice.Builder builder = Choice.builder(labels); + configure.accept(builder); + return choice(key, labels, builder.build()); + } + + public static ScoreAsk score(String key, Score question) { + return new ScoreAsk(key, question); + } + + public static ScoreAsk score(String key, Consumer configure) { + return score(key, Score.of(configure)); + } + + /** The question id the answer comes back under. */ + public String key() { + return key; + } + + public abstract TypeSafeQuestion question(); + + abstract A read(TypeSafeResponse response); + + @Override + public String toString() { + return "%s[key=%s, question=%s]".formatted(getClass().getSimpleName(), key, question()); + } +} diff --git a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/ChoiceAsk.java b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/ChoiceAsk.java new file mode 100644 index 0000000..6d65c30 --- /dev/null +++ b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/ChoiceAsk.java @@ -0,0 +1,52 @@ +package io.github.premocloud.typesafe; + +import java.util.Arrays; +import java.util.Objects; +import java.util.function.Function; + +/** + * A {@link Choice} under its key, with the type its labels read back as. Create one with {@link Ask#choice}. + * + * @param {@code String}, or the enum whose constant names are the labels + */ +public final class ChoiceAsk extends Ask> { + + private final Class labels; + private final Choice question; + private final Function> read; + + /** Checks an enum choice's labels here, since {@code Choice}'s constructor accepts any label for any {@code E}. */ + ChoiceAsk(String key, Class labels, Choice question, Function> read) { + super(key); + Objects.requireNonNull(labels, "labels"); + Objects.requireNonNull(question, "question"); + + if (labels.isEnum()) { + for (String label : question.criteria().keySet()) { + if (Arrays.stream(labels.getEnumConstants()).noneMatch(constant -> ((Enum) constant).name().equals(label))) { + throw new IllegalArgumentException("Choice '%s' is not a constant of %s; expected one of %s" + .formatted(label, labels.getSimpleName(), Arrays.toString(labels.getEnumConstants()))); + } + } + } + + this.labels = labels; + this.question = question; + this.read = read; + } + + /** {@code String.class}, or the enum the labels are constants of. */ + public Class labels() { + return labels; + } + + @Override + public Choice question() { + return question; + } + + @Override + ChoiceAnswer read(TypeSafeResponse response) { + return read.apply(response); + } +} diff --git a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/NoulAsk.java b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/NoulAsk.java new file mode 100644 index 0000000..181cbe4 --- /dev/null +++ b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/NoulAsk.java @@ -0,0 +1,24 @@ +package io.github.premocloud.typesafe; + +import java.util.Objects; + +/** A {@link Noul} under its key. Create one with {@link Ask#noul}. */ +public final class NoulAsk extends Ask { + + private final Noul question; + + NoulAsk(String key, Noul question) { + super(key); + this.question = Objects.requireNonNull(question, "question"); + } + + @Override + public Noul question() { + return question; + } + + @Override + NoulAnswer read(TypeSafeResponse response) { + return response.answer(key(), NoulAnswer.class); + } +} diff --git a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/ScoreAsk.java b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/ScoreAsk.java new file mode 100644 index 0000000..a8bfeb1 --- /dev/null +++ b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/ScoreAsk.java @@ -0,0 +1,24 @@ +package io.github.premocloud.typesafe; + +import java.util.Objects; + +/** A {@link Score} under its key. Create one with {@link Ask#score}. */ +public final class ScoreAsk extends Ask { + + private final Score question; + + ScoreAsk(String key, Score question) { + super(key); + this.question = Objects.requireNonNull(question, "question"); + } + + @Override + public Score question() { + return question; + } + + @Override + ScoreAnswer read(TypeSafeResponse response) { + return response.score(key()); + } +} diff --git a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeClient.java b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeClient.java index 59b5aff..f7003ec 100644 --- a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeClient.java +++ b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeClient.java @@ -111,6 +111,14 @@ public TypeSafeResponse systemOne(Object state, Map r.state(email).ask(URGENT, DEPT), options)}. + */ + public TypeSafeResponse systemOne(Object state, Ask first, Ask... more) { + return systemOne(TypeSafeRequest.of(state, first, more), RequestOptions.NONE); + } + /** {@code client.systemOne(r -> r.state(ticket).noul("urgent", n -> n.instructions("Is `ticket` urgent?")))}. */ public TypeSafeResponse systemOne(Consumer configure) { return systemOne(TypeSafeRequest.of(configure), RequestOptions.NONE); @@ -144,6 +152,11 @@ public CompletableFuture systemOneAsync(Object state, Map systemOneAsync(Object state, Ask first, Ask... more) { + return systemOneAsync(TypeSafeRequest.of(state, first, more), RequestOptions.NONE); + } + /** {@code client.systemOneAsync(r -> r.state(ticket).noul("urgent", n -> n.instructions("Is `ticket` urgent?")))}. */ public CompletableFuture systemOneAsync(Consumer configure) { return systemOneAsync(TypeSafeRequest.of(configure), RequestOptions.NONE); diff --git a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeRequest.java b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeRequest.java index 95ab683..9d1efa9 100644 --- a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeRequest.java +++ b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeRequest.java @@ -4,9 +4,11 @@ import org.jspecify.annotations.Nullable; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.function.Consumer; /** @@ -33,6 +35,11 @@ public static TypeSafeRequest of(Object state, Map first, Ask... more) { + return builder().state(state).ask(first).ask(more).build(); + } + public static TypeSafeRequest of(Consumer configure) { Builder builder = builder(); configure.accept(builder); @@ -51,15 +58,20 @@ public static final class Builder { private @Nullable Object state; private @Nullable String model; private final Map questions = new LinkedHashMap<>(); + private final Set askedKeys = new HashSet<>(); public Builder state(Object state) { this.state = state; return this; } - /** Adds one named field to an object state. The state must already be a {@code Map}. */ + /** Adds one named field to an object state, starting one if no state is set yet. A state already set must be a {@code Map}. */ @SuppressWarnings("unchecked") public Builder state(String key, Object value) { + if (Objects.isNull(state)) { + state = new LinkedHashMap(); + } + if (!(state instanceof Map)) { throw new IllegalStateException("state(key, value) needs an object state; call state(Map) first"); } @@ -94,7 +106,34 @@ public Builder score(String key, Consumer configure) { return question(key, Score.of(configure)); } + /** + * Adds each ask's question under its key. + * + * @throws IllegalArgumentException if the request already has a question under one of the keys + */ + public Builder ask(Ask... asks) { + for (Ask ask : asks) { + if (questions.containsKey(ask.key())) { + throw new IllegalArgumentException("The request already has a question '%s'".formatted(ask.key())); + } + + questions.put(ask.key(), ask.question()); + askedKeys.add(ask.key()); + } + + return this; + } + + /** + * Adds a question, replacing any earlier question under the same key. + * + * @throws IllegalArgumentException if the key belongs to an {@link Ask}, whose answer would no longer match it + */ public Builder question(String key, TypeSafeQuestion question) { + if (askedKeys.contains(key)) { + throw new IllegalArgumentException("Question '%s' was added with ask(...) and cannot be replaced".formatted(key)); + } + questions.put(key, question); return this; } diff --git a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeResponse.java b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeResponse.java index f6284f1..1e35207 100644 --- a/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeResponse.java +++ b/typesafe-sdk/src/main/java/io/github/premocloud/typesafe/TypeSafeResponse.java @@ -9,7 +9,8 @@ /** * Answers keyed by the question ids from the request. Read one answer with {@link #noul}, {@link #choice}, or - * {@link #score}, or all answers of a kind with {@link #nouls()}, {@link #choices()}, or {@link #scores()}. + * {@link #score}, or through the {@link Ask} that asked it with {@link #answer(Ask)}, or all answers of a kind with + * {@link #nouls()}, {@link #choices()}, or {@link #scores()}. */ @JsonIgnoreProperties(ignoreUnknown = true) public record TypeSafeResponse(String model, Map answers, TypeSafeUsage usage) { @@ -44,6 +45,14 @@ public ScoreAnswer score(String key) { return answer(key, ScoreAnswer.class); } + /** + * @return the answer to {@code ask}, typed by it: {@code Dept dept = response.answer(DEPT).choice()} + * @throws IllegalArgumentException as the keyed accessors do: no answer under its key, or an enum label not in its enum + */ + public A answer(Ask ask) { + return ask.read(this); + } + public Map nouls() { return answersOf(NoulAnswer.class); } @@ -57,7 +66,7 @@ public Map scores() { return answersOf(ScoreAnswer.class); } - private T answer(String key, Class expected) { + T answer(String key, Class expected) { TypeSafeAnswer answer = answers.get(key); if (Objects.isNull(answer)) { diff --git a/typesafe-sdk/src/test/java/io/github/premocloud/typesafe/TypeSafeClientTest.java b/typesafe-sdk/src/test/java/io/github/premocloud/typesafe/TypeSafeClientTest.java index df4b95a..b4f96e3 100644 --- a/typesafe-sdk/src/test/java/io/github/premocloud/typesafe/TypeSafeClientTest.java +++ b/typesafe-sdk/src/test/java/io/github/premocloud/typesafe/TypeSafeClientTest.java @@ -343,6 +343,78 @@ void systemOneRejectsAChoiceLabelThatIsNotAConstantOfTheEnum() { assertTrue(exception.getMessage().contains("[LOW, HIGH]"), exception.getMessage()); } + private static final Ask IS_PHISHING = Ask.noul("is_phishing", n -> n.instructions("Is `email` phishing?")); + private static final Ask> SPAM_CATEGORY = Ask.choice("spam_category", SpamCategory.class, c -> c + .instructions("Which category?") + .option(SpamCategory.PHISHING, "Credential theft") + .option(SpamCategory.MARKETING, "Promotions")); + private static final Ask URGENCY = Ask.score("urgency", Score.of("How urgent?", "none", "soft", "threatening")); + + @Test + void systemOneAsksThroughAsksAndReadsTheAnswersBackTyped() throws Exception { + server.reply(200, RESPONSE_JSON); + + TypeSafeResponse response = client.systemOne(Map.of("email", Map.of("subject", "URGENT")), IS_PHISHING, SPAM_CATEGORY, URGENCY); + + JsonNode sent = objectMapper.readTree(server.recorded().get(0).body()); + assertEquals("URGENT", sent.at("/state/email/subject").asText()); + assertEquals(List.of("is_phishing", "spam_category", "urgency"), fieldNames(sent.at("/questions"))); + assertEquals("Credential theft", sent.at("/questions/spam_category/criteria/PHISHING").asText()); + + double phishing = response.answer(IS_PHISHING).noul(); + SpamCategory category = response.answer(SPAM_CATEGORY).choice(); + ScoreAnswer urgency = response.answer(URGENCY); + assertEquals(0.93, phishing); + assertEquals(SpamCategory.PHISHING, category); + assertEquals(0.1, response.answer(SPAM_CATEGORY).probabilities().get(SpamCategory.MARKETING)); + assertEquals(1.7, urgency.score()); + } + + @Test + void anAskWithStringLabelsReadsBackAsStrings() { + server.reply(200, RESPONSE_JSON); + Ask> category = Ask.choice("spam_category", Choice.of("Which category?", "PHISHING", "MARKETING")); + + TypeSafeResponse response = client.systemOne(r -> r.state("email", "text").ask(category, IS_PHISHING, URGENCY)); + + assertEquals("PHISHING", response.answer(category).choice()); + } + + @Test + void readingAnAskThatWasNotInTheRequestFailsLikeAMissingKey() { + server.reply(200, RESPONSE_JSON); + TypeSafeResponse response = client.systemOne(spamRequest()); + + IllegalArgumentException missing = assertThrows(IllegalArgumentException.class, + () -> response.answer(Ask.noul("nope", Noul.of("?")))); + assertTrue(missing.getMessage().contains("nope"), missing.getMessage()); + + IllegalArgumentException notAConstant = assertThrows(IllegalArgumentException.class, + () -> response.answer(Ask.choice("spam_category", Urgency.class, Choice.of("?", Urgency.class)))); + assertTrue(notAConstant.getMessage().contains("[LOW, HIGH]"), notAConstant.getMessage()); + } + + @Test + void systemOneAsyncAsksThroughAsks() throws Exception { + server.reply(200, RESPONSE_JSON); + server.reply(200, RESPONSE_JSON); + + TypeSafeResponse flat = client.systemOneAsync(Map.of("email", "text"), IS_PHISHING, SPAM_CATEGORY, URGENCY) + .get(5, TimeUnit.SECONDS); + TypeSafeResponse configured = client.systemOneAsync(r -> r.state("email", "text").ask(IS_PHISHING, SPAM_CATEGORY, URGENCY), + RequestOptions.of(o -> o.header("X-Trace", "t3"))).get(5, TimeUnit.SECONDS); + + assertEquals(SpamCategory.PHISHING, flat.answer(SPAM_CATEGORY).choice()); + assertEquals(0.93, configured.answer(IS_PHISHING).noul()); + assertEquals("t3", server.recorded().get(1).headers().getFirst("X-Trace")); + } + + private static List fieldNames(JsonNode node) { + List names = new java.util.ArrayList<>(); + node.fieldNames().forEachRemaining(names::add); + return names; + } + @Test void systemOneRejectsChoiceAnswerMissingItsChoice() { server.reply(200, """ diff --git a/typesafe-sdk/src/test/java/io/github/premocloud/typesafe/TypeSafeRequestTest.java b/typesafe-sdk/src/test/java/io/github/premocloud/typesafe/TypeSafeRequestTest.java index 3ad140d..96e84dc 100644 --- a/typesafe-sdk/src/test/java/io/github/premocloud/typesafe/TypeSafeRequestTest.java +++ b/typesafe-sdk/src/test/java/io/github/premocloud/typesafe/TypeSafeRequestTest.java @@ -128,6 +128,18 @@ void structuredStateAndInstructionsPassThrough() { assertThrows(IllegalStateException.class, () -> TypeSafeRequest.builder().state("text").state("k", "v")); } + @Test + void namedStateFieldsStartAnObjectStateWhenNoneIsSet() { + TypeSafeRequest request = TypeSafeRequest.of(r -> r + .state("email", "Help! My payouts have been failing for 3 days.") + .state("context", Map.of("tier", "enterprise")) + .noul("is_urgent", n -> n.instructions("Does `email` convey urgency?"))); + + JsonNode json = objectMapper.valueToTree(request); + assertEquals("Help! My payouts have been failing for 3 days.", json.at("/state/email").asText()); + assertEquals("enterprise", json.at("/state/context/tier").asText()); + } + enum Dept { BILLING, SHIPPING, SECURITY } @Test @@ -166,6 +178,86 @@ void prebuiltQuestionsCanBeReused() { assertSame(shared, request.questions().get("urgent")); } + private static final Ask URGENT = Ask.noul("urgent", n -> n.instructions("Does `email` need a reply today?")); + private static final Ask> DEPT = Ask.choice("dept", Dept.class, c -> c + .instructions("Which team should handle `email`?") + .option(Dept.BILLING, "Invoices, refunds, payment methods") + .option(Dept.SECURITY)); + + @Test + void asksAddTheirQuestionsUnderTheirKeys() { + TypeSafeRequest request = TypeSafeRequest.of(r -> r + .state("email", "text") + .ask(URGENT, DEPT) + .noul("spam", n -> n.instructions("Is `email` spam?"))); + + assertEquals(List.of("urgent", "dept", "spam"), List.copyOf(request.questions().keySet())); + assertSame(URGENT.question(), request.questions().get("urgent")); + assertSame(DEPT.question(), request.questions().get("dept")); + assertEquals(objectMapper.valueToTree(Choice.builder(Dept.class) + .instructions("Which team should handle `email`?") + .option(Dept.BILLING, "Invoices, refunds, payment methods") + .option(Dept.SECURITY).build()), + objectMapper.valueToTree(request).at("/questions/dept")); + + TypeSafeRequest flat = TypeSafeRequest.of(Map.of("email", "text"), URGENT, DEPT); + assertEquals(request.questions().get("dept"), flat.questions().get("dept")); + assertEquals(List.of("urgent", "dept"), List.copyOf(flat.questions().keySet())); + } + + @Test + void anEnumChoiceAskRejectsALabelThatIsNotAConstant() { + Choice mislabeled = new Choice<>("Which team?", Map.of("LEGAL", "Contracts")); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> Ask.choice("dept", Dept.class, mislabeled)); + + assertTrue(exception.getMessage().contains("'LEGAL'"), exception.getMessage()); + assertTrue(exception.getMessage().contains("[BILLING, SHIPPING, SECURITY]"), exception.getMessage()); + } + + @Test + void asksExposeTheirQuestionTypedAndMirrorTheQuestionTypes() { + NoulAsk urgent = Ask.noul("urgent", Noul.of("Is it urgent?")); + ChoiceAsk dept = Ask.choice("dept", Dept.class, Choice.of("Which team?", Dept.class)); + ChoiceAsk category = Ask.choice("category", c -> c.option("billing").option("other")); + ScoreAsk severity = Ask.score("severity", s -> s.level("minor").level("major")); + + assertEquals("Is it urgent?", urgent.question().instructions()); + assertEquals(Dept.class, dept.labels()); + assertEquals(3, dept.question().criteria().size()); + assertEquals(String.class, category.labels()); + assertEquals(2, severity.question().criteria().size()); + + assertEquals(List.of(NoulAsk.class, ChoiceAsk.class, ScoreAsk.class), List.of(Ask.class.getPermittedSubclasses())); + } + + @Test + void anAskNeedsAKeyAndAQuestion() { + assertThrows(NullPointerException.class, () -> Ask.noul(null, Noul.of("Is it urgent?"))); + assertThrows(NullPointerException.class, () -> Ask.noul("urgent", (Noul) null)); + assertThrows(NullPointerException.class, () -> Ask.choice("dept", Dept.class, (Choice) null)); + assertThrows(NullPointerException.class, () -> Ask.choice("category", (Choice) null)); + assertThrows(NullPointerException.class, () -> Ask.score("severity", (Score) null)); + } + + @Test + void anAskedKeyCannotBeAskedOrReplacedAgain() { + Ask otherUrgent = Ask.noul("urgent", Noul.of("Is it urgent?")); + + IllegalArgumentException twice = assertThrows(IllegalArgumentException.class, + () -> TypeSafeRequest.of(r -> r.state("text").ask(URGENT, otherUrgent))); + assertTrue(twice.getMessage().contains("'urgent'"), twice.getMessage()); + assertThrows(IllegalArgumentException.class, + () -> TypeSafeRequest.of(r -> r.state("text").noul("urgent", n -> n.instructions("?")).ask(URGENT))); + assertThrows(IllegalArgumentException.class, + () -> TypeSafeRequest.of(r -> r.state("text").ask(URGENT).noul("urgent", n -> n.instructions("?")))); + + TypeSafeRequest replaced = TypeSafeRequest.of(r -> r.state("text") + .noul("urgent", n -> n.instructions("first")).noul("urgent", n -> n.instructions("second"))); + assertEquals("second", replaced.questions().get("urgent").instructions(), "keys without an Ask still replace"); + } + @Test void validationMatchesTheOtherSdks() { assertThrows(IllegalStateException.class, () -> TypeSafeRequest.of(r -> r.noul("q", n -> n.instructions("Urgent?"))));