Skip to content
Open
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,14 @@ A `TypeSafeClient` bean is then available for injection. The starter creates it
you define your own, and reuses the application's `ObjectMapper`. Properties, customization, testing, and troubleshooting
are covered in the [starter README](typesafe-sdk-spring-boot-starter/README.md).

## Examples

Runnable programs in [`examples/`](examples): ticket triage, typed routing, an async batch, and a Spring Boot app.

```
TYPESAFE_API_KEY=your-key ./gradlew :examples:ticket-triage:run
```

## Development

```
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ plugins {
id("com.vanniktech.maven.publish") version "0.37.0" apply false
}

subprojects {
configure(listOf(project(":typesafe-sdk"), project(":typesafe-sdk-spring-boot-starter"))) {
apply(plugin = "java-library")
apply(plugin = "com.vanniktech.maven.publish")

Expand Down
14 changes: 14 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Examples

| Example | Shows |
|---|---|
| [ticket-triage](ticket-triage) | Keyed questions, models listing, and API errors; a port of the JS SDK's demo |
| [typed-routing](typed-routing) | `Ask` handles and an enum `Choice`, routed by an exhaustive `switch` |
| [async-batch](async-batch) | `systemOneAsync` fan-out with per-call options, retries, and failures per item |
| [spring-boot](spring-boot) | The starter behind a REST endpoint, with API errors mapped to `ProblemDetail` |

```sh
TYPESAFE_API_KEY=your-key ./gradlew :examples:ticket-triage:run
```

Each example has a smoke test against an in-process stub server, so `./gradlew build` checks them all with no API key.
19 changes: 19 additions & 0 deletions examples/async-batch/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Async batch

Jev moderates eight reviews at once, with a per-call timeout, patient retries, and failures that don't sink the batch.

```sh
TYPESAFE_API_KEY=your-key ./gradlew :examples:async-batch:run
```

```
review spam tone
Five stars. Survived a drop onto concrete without a scratch. 3% 3.84 (glowing)
Battery died after a week. Absolute garbage, never again. 2% 0.13 (hostile)
BEST PRICES ON WATCHES >>> cheap-watch-deals.example 98% 3.12 (pleased)
It's fine. Does what the box says. 6% 2.02 (neutral)
Love it so much I bought one for my sister too! 5% 3.98 (glowing)
Shipped late and the strap was the wrong color. 3% 1.00 (disappointed)
Earn $5,000 a week from home, DM me for details 98% 2.74 (pleased)
Decent, though the manual could be clearer. 2% 2.24 (neutral)
```
13 changes: 13 additions & 0 deletions examples/async-batch/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
plugins {
application
}

dependencies {
// in your project: implementation("io.github.premo-cloud:typesafe-sdk:0.6.0")
implementation(project(":typesafe-sdk"))
runtimeOnly("org.slf4j:slf4j-simple:2.0.16")
}

application {
mainClass.set("io.github.premocloud.typesafe.examples.batch.AsyncBatch")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package io.github.premocloud.typesafe.examples.batch;

import io.github.premocloud.typesafe.Ask;
import io.github.premocloud.typesafe.NoulAsk;
import io.github.premocloud.typesafe.RequestOptions;
import io.github.premocloud.typesafe.RetryPolicy;
import io.github.premocloud.typesafe.Score;
import io.github.premocloud.typesafe.ScoreAnswer;
import io.github.premocloud.typesafe.ScoreAsk;
import io.github.premocloud.typesafe.TypeSafeApiException;
import io.github.premocloud.typesafe.TypeSafeClient;
import io.github.premocloud.typesafe.TypeSafeResponse;

import java.time.Duration;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;

/** Moderates a batch of reviews concurrently: one request per review, and no thread held while they are in flight. */
public final class AsyncBatch {

record Row(String review, String spam, String tone) {

static Row of(String review, TypeSafeResponse response) {
double spam = response.answer(IS_SPAM).noul();
ScoreAnswer tone = response.answer(TONE);
return new Row(review, "%.0f%%".formatted(spam * 100),
"%.2f (%s)".formatted(tone.score(), TONES.get((int) Math.round(tone.score()))));
}

static Row failed(String review, Throwable error) {
// Dependent stages wrap the SDK's exception
Throwable cause = error instanceof CompletionException ? error.getCause() : error;
String requestId = cause instanceof TypeSafeApiException api
? api.requestId().map(id -> " (request " + id + ")").orElse("") : "";
return new Row(review, "failed", cause.getMessage() + requestId);
}
}

static final NoulAsk IS_SPAM = Ask.noul("is_spam", n -> n
.instructions("Is `review` spam rather than a customer's opinion of the product?")
.whenTrue("Advertises something else, pushes a link or a coupon, or says nothing about the product")
.whenFalse("A real opinion, however short, rude, or badly spelled"));

static final List<String> TONES = List.of("hostile", "disappointed", "neutral", "pleased", "glowing");

static final ScoreAsk TONE = Ask.score("tone",
Score.of("How does the author of `review` feel about the product?", TONES));

static final RequestOptions OPTIONS = RequestOptions.of(o -> o.timeout(Duration.ofSeconds(20)));

static final List<String> REVIEWS = List.of(
"Five stars. Survived a drop onto concrete without a scratch.",
"Battery died after a week. Absolute garbage, never again.",
"BEST PRICES ON WATCHES >>> cheap-watch-deals.example",
"It's fine. Does what the box says.",
"Love it so much I bought one for my sister too!",
"Shipped late and the strap was the wrong color.",
"Earn $5,000 a week from home, DM me for details",
"Decent, though the manual could be clearer.");

static final String COLUMNS = "%-60s %6s %s%n";

public static void main(String[] args) {
// Eight requests at once can trip a rate limit, so the batch waits it out more patiently than the default
run(TypeSafeClient.builder()
.retryPolicy(RetryPolicy.of(r -> r.maxRetries(4).backoffMax(Duration.ofSeconds(10))))
.build());
}

static void run(TypeSafeClient client) {
List<CompletableFuture<Row>> rows = REVIEWS.stream()
.map(review -> client
.systemOneAsync(r -> r.state("review", review).ask(IS_SPAM, TONE), OPTIONS)
.thenApply(response -> Row.of(review, response))
.exceptionally(error -> Row.failed(review, error)))
.toList();

CompletableFuture.allOf(rows.toArray(CompletableFuture[]::new)).join();

System.out.printf(COLUMNS, "review", "spam", "tone");
rows.stream().map(CompletableFuture::join)
.forEach(row -> System.out.printf(COLUMNS, row.review(), row.spam(), row.tone()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package io.github.premocloud.typesafe.examples.batch;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.premocloud.typesafe.examples.support.StubTypeSafeServer;
import io.github.premocloud.typesafe.examples.support.StubTypeSafeServer.Request;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.util.HashSet;
import java.util.Set;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

class AsyncBatchTest {

static final String REPLY = """
{
"model": "jev-1.13.0",
"answers": {
"is_spam": {"type": "noul", "noul": 0.04},
"tone": {"type": "score", "score": 3.8, "confidence": 0.9, "probabilities": {"3": 0.2, "4": 0.8}}
},
"usage": {"input_tokens": 80, "output_tokens": 12}
}
""";

@Test
void sendsEachReviewOnce() throws IOException {
try (StubTypeSafeServer stub = new StubTypeSafeServer().reply("/v1/systemone", REPLY)) {
AsyncBatch.run(stub.client());

assertEquals(AsyncBatch.REVIEWS.size(), stub.requests().size());
assertTrue(stub.requests().stream().allMatch(request -> request.path().equals("/v1/systemone")));
Set<String> sent = new HashSet<>();
for (Request request : stub.requests()) {
sent.add(new ObjectMapper().readTree(request.body()).at("/state/review").asText());
}
assertEquals(Set.copyOf(AsyncBatch.REVIEWS), sent);
}
}

@Test
void failedReviewsDoNotSinkTheBatch() throws IOException {
try (StubTypeSafeServer stub = new StubTypeSafeServer().reply("/v1/systemone", 500, "{\"error\": \"unavailable\"}")) {
AsyncBatch.run(stub.client());

assertEquals(AsyncBatch.REVIEWS.size(), stub.requests().size());
}
}

}
30 changes: 30 additions & 0 deletions examples/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Shared setup for the examples: the same toolchain and Java 17 target as the SDK. Examples are never published.
subprojects {
apply(plugin = "java")

repositories {
mavenCentral()
}

extensions.configure<JavaPluginExtension> {
toolchain.languageVersion.set(JavaLanguageVersion.of(21))
}

tasks.withType<JavaCompile>().configureEach {
options.release.set(17)
options.encoding = "UTF-8"
}

tasks.withType<Test>().configureEach {
useJUnitPlatform()
}

if (name != "support") {
dependencies {
"testImplementation"(project(":examples:support"))
"testImplementation"(platform("org.junit:junit-bom:5.14.4"))
"testImplementation"("org.junit.jupiter:junit-jupiter")
"testRuntimeOnly"("org.junit.platform:junit-platform-launcher")
}
}
}
16 changes: 16 additions & 0 deletions examples/spring-boot/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Spring Boot

The starter in a real application: `POST /tickets/route` asks Jev which team owns a ticket and whether to escalate it.

```sh
TYPESAFE_API_KEY=your-key ./gradlew :examples:spring-boot:run
```

```sh
curl -s localhost:8080/tickets/route -H 'Content-Type: application/json' \
-d '{"subject": "Charged twice", "body": "My card was billed twice for March. Refund it today or I cancel."}'
```

```json
{"team":"BILLING","queue":"finance","escalate":true}
```
16 changes: 16 additions & 0 deletions examples/spring-boot/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
plugins {
application
}

val springBootVersion = "3.1.2"

dependencies {
// in your project: implementation("io.github.premo-cloud:typesafe-sdk-spring-boot-starter:0.6.0")
implementation(project(":typesafe-sdk-spring-boot-starter"))
implementation("org.springframework.boot:spring-boot-starter-web:$springBootVersion")
testImplementation("org.springframework.boot:spring-boot-starter-test:$springBootVersion")
}

application {
mainClass.set("io.github.premocloud.typesafe.examples.spring.TicketRoutingApplication")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package io.github.premocloud.typesafe.examples.spring;

import io.github.premocloud.typesafe.Ask;
import io.github.premocloud.typesafe.ChoiceAsk;
import io.github.premocloud.typesafe.NoulAsk;
import io.github.premocloud.typesafe.TypeSafeClient;
import io.github.premocloud.typesafe.TypeSafeResponse;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

/** Routes a posted ticket to a team's queue, escalating when it needs a lead. */
@RestController
@RequestMapping("/tickets")
public class TicketController {

public enum Team { BILLING, TECH_SUPPORT, SALES }

public record Ticket(String subject, String body) {
}

public record Route(Team team, String queue, boolean escalate) {
}

static final ChoiceAsk<Team> TEAM = Ask.choice("team", Team.class, c -> c
.instructions("Which team should handle `ticket`?")
.option(Team.BILLING, "Invoices, charges, refunds, payment methods")
.option(Team.TECH_SUPPORT, o -> o
.what("Something the customer already has is broken or confusing")
.notFor("Questions about plans or features they do not have yet"))
.option(Team.SALES, o -> o
.what("Buying, upgrading, pricing, or evaluating the product")
.examples("Do you offer annual plans?", "Can we get a demo for our team?")));

static final NoulAsk ESCALATE = Ask.noul("escalate", n -> n
.instructions("Should `ticket` go to a team lead right away?")
.whenTrue("Threatens to cancel, sue, or charge back; reports data loss, an outage, or a security problem")
.whenFalse("A routine request a front-line agent can close"));

private final TypeSafeClient typeSafe;

public TicketController(TypeSafeClient typeSafe) {
this.typeSafe = typeSafe;
}

@PostMapping("/route")
public Route route(@RequestBody Ticket ticket) {
TypeSafeResponse response = typeSafe.systemOne(Map.of("ticket", ticket), TEAM, ESCALATE);
Team team = response.answer(TEAM).choice();
return new Route(team, queue(team), response.answer(ESCALATE).noul() > 0.5);
}

/** Add a Team and this stops compiling until the new constant has a queue. */
private static String queue(Team team) {
return switch (team) {
case BILLING -> "finance";
case TECH_SUPPORT -> "helpdesk";
case SALES -> "sales";
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package io.github.premocloud.typesafe.examples.spring;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/** Serves ticket routing over HTTP with a TypeSafeClient the starter configures from properties. */
@SpringBootApplication
public class TicketRoutingApplication {

public static void main(String[] args) {
SpringApplication.run(TicketRoutingApplication.class, args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package io.github.premocloud.typesafe.examples.spring;

import io.github.premocloud.typesafe.TypeSafeApiException;
import io.github.premocloud.typesafe.TypeSafeException;
import io.github.premocloud.typesafe.TypeSafeTimeoutException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/** Turns a failed call to Jev into a gateway error, so no route is ever guessed. */
@RestControllerAdvice
class TypeSafeErrorHandler {

@ExceptionHandler
ProblemDetail timedOut(TypeSafeTimeoutException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.GATEWAY_TIMEOUT, e.getMessage());
}

/** Only an API reply carries a request id; connection failures never reached the API. */
@ExceptionHandler
ProblemDetail failed(TypeSafeException e) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_GATEWAY, e.getMessage());

if (e instanceof TypeSafeApiException api) {
api.requestId().ifPresent(id -> problem.setProperty("typesafeRequestId", id));
}

return problem;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
typesafe.api-key=${TYPESAFE_API_KEY:}
Loading
Loading