Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased

- Typed answer lookup: `Ask<A>` pairs a key, a question, and its answer type. `Ask.noul`, `Ask.choice`, and `Ask.score` declare one, returning the sealed subtypes `NoulAsk`, `ChoiceAsk<E>`, 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<E>`, 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).
Expand Down
146 changes: 84 additions & 62 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<NoulAnswer> 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<ChoiceAnswer<Category>> 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<ScoreAnswer> 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", "[email protected]",
"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<String> 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<Category>`, `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> dept = Choice.of("Which team should handle `email`?", Dept.class); // one option per constant
Choice<Dept> 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<ChoiceAnswer<Dept>> DEPT =
Ask.choice("dept", Dept.class, Choice.of("Which team should handle `email`?", Dept.class)); // one option per constant

ChoiceAnswer<Dept> answer = response.choice("dept", Dept.class);
ChoiceAnswer<Dept> 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<String>` 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<E>`, 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

Expand Down Expand Up @@ -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)));
```

Expand All @@ -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; });
```

Expand Down
12 changes: 7 additions & 5 deletions typesafe-sdk-spring-boot-starter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChoiceAnswer<Department>> 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();
}
}
```
Expand Down
92 changes: 92 additions & 0 deletions typesafe-sdk/src/main/java/io/github/premocloud/typesafe/Ask.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <pre>{@code
* static final Ask<NoulAnswer> URGENT = Ask.noul("urgent", n -> n.instructions("Does `email` need a reply today?"));
* static final Ask<ChoiceAnswer<Dept>> 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();
* }</pre>
*
* 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.
*
* <p>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 <A> what {@link TypeSafeResponse#answer(Ask)} returns: {@link NoulAnswer}, {@code ChoiceAnswer<E>}, or {@link ScoreAnswer}
*/
public abstract sealed class Ask<A> 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<Noul.Builder> 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<String> choice(String key, Choice<String> question) {
return new ChoiceAsk<>(key, String.class, question, response -> response.choice(key));
}

public static ChoiceAsk<String> choice(String key, Consumer<Choice.Builder<String>> 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 <E extends Enum<E>> ChoiceAsk<E> choice(String key, Class<E> labels, Choice<E> question) {
return new ChoiceAsk<>(key, labels, question, response -> response.choice(key, labels));
}

public static <E extends Enum<E>> ChoiceAsk<E> choice(String key, Class<E> labels, Consumer<Choice.Builder<E>> configure) {
Choice.Builder<E> 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<Score.Builder> 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());
}
}
Loading
Loading