From 4818883ca06d127a1cd4d7ad5d9daf9f75dcd94e Mon Sep 17 00:00:00 2001 From: Garret Premo Date: Mon, 5 Oct 2026 12:52:51 -0400 Subject: [PATCH] Add runnable examples, each smoke-tested against a stub Four examples under examples/, each its own Gradle project with a README, a run task, and a smoke test: - ticket-triage: keyed questions, models listing, and API errors; a port of the JavaScript SDK's demo. - typed-routing: Ask handles and an enum Choice, routed to a queue by an exhaustive switch. - async-batch: systemOneAsync fan-out with per-call options, a custom retry policy, and failures per item that don't sink the batch. - spring-boot: the starter behind POST /tickets/route, with TypeSafe failures mapped to 502 or 504 ProblemDetail responses. Each example takes its client through run(TypeSafeClient), so the tests point it at examples/support's in-process stub, and ./gradlew build checks every example with no API key. The README samples are output from real runs. The root build now configures only the two library modules, so nothing under examples/ is published. Refs #20 --- README.md | 8 ++ build.gradle.kts | 2 +- examples/README.md | 14 +++ examples/async-batch/README.md | 19 ++++ examples/async-batch/build.gradle.kts | 13 +++ .../typesafe/examples/batch/AsyncBatch.java | 85 ++++++++++++++++ .../examples/batch/AsyncBatchTest.java | 52 ++++++++++ examples/build.gradle.kts | 30 ++++++ examples/spring-boot/README.md | 16 +++ examples/spring-boot/build.gradle.kts | 16 +++ .../examples/spring/TicketController.java | 64 ++++++++++++ .../spring/TicketRoutingApplication.java | 13 +++ .../examples/spring/TypeSafeErrorHandler.java | 31 ++++++ .../src/main/resources/application.properties | 1 + .../examples/spring/TicketControllerTest.java | 98 +++++++++++++++++++ examples/support/build.gradle.kts | 6 ++ .../examples/support/StubTypeSafeServer.java | 75 ++++++++++++++ examples/ticket-triage/README.md | 16 +++ examples/ticket-triage/build.gradle.kts | 13 +++ .../examples/triage/TicketTriage.java | 57 +++++++++++ .../examples/triage/TicketTriageTest.java | 71 ++++++++++++++ examples/typed-routing/README.md | 15 +++ examples/typed-routing/build.gradle.kts | 13 +++ .../examples/routing/TypedRouting.java | 74 ++++++++++++++ .../examples/routing/TypedRoutingTest.java | 48 +++++++++ settings.gradle.kts | 8 ++ 26 files changed, 857 insertions(+), 1 deletion(-) create mode 100644 examples/README.md create mode 100644 examples/async-batch/README.md create mode 100644 examples/async-batch/build.gradle.kts create mode 100644 examples/async-batch/src/main/java/io/github/premocloud/typesafe/examples/batch/AsyncBatch.java create mode 100644 examples/async-batch/src/test/java/io/github/premocloud/typesafe/examples/batch/AsyncBatchTest.java create mode 100644 examples/build.gradle.kts create mode 100644 examples/spring-boot/README.md create mode 100644 examples/spring-boot/build.gradle.kts create mode 100644 examples/spring-boot/src/main/java/io/github/premocloud/typesafe/examples/spring/TicketController.java create mode 100644 examples/spring-boot/src/main/java/io/github/premocloud/typesafe/examples/spring/TicketRoutingApplication.java create mode 100644 examples/spring-boot/src/main/java/io/github/premocloud/typesafe/examples/spring/TypeSafeErrorHandler.java create mode 100644 examples/spring-boot/src/main/resources/application.properties create mode 100644 examples/spring-boot/src/test/java/io/github/premocloud/typesafe/examples/spring/TicketControllerTest.java create mode 100644 examples/support/build.gradle.kts create mode 100644 examples/support/src/main/java/io/github/premocloud/typesafe/examples/support/StubTypeSafeServer.java create mode 100644 examples/ticket-triage/README.md create mode 100644 examples/ticket-triage/build.gradle.kts create mode 100644 examples/ticket-triage/src/main/java/io/github/premocloud/typesafe/examples/triage/TicketTriage.java create mode 100644 examples/ticket-triage/src/test/java/io/github/premocloud/typesafe/examples/triage/TicketTriageTest.java create mode 100644 examples/typed-routing/README.md create mode 100644 examples/typed-routing/build.gradle.kts create mode 100644 examples/typed-routing/src/main/java/io/github/premocloud/typesafe/examples/routing/TypedRouting.java create mode 100644 examples/typed-routing/src/test/java/io/github/premocloud/typesafe/examples/routing/TypedRoutingTest.java diff --git a/README.md b/README.md index 5d21315..9a4d57c 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/build.gradle.kts b/build.gradle.kts index 7a57210..4f50495 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..3e6a529 --- /dev/null +++ b/examples/README.md @@ -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. diff --git a/examples/async-batch/README.md b/examples/async-batch/README.md new file mode 100644 index 0000000..8ad9a4a --- /dev/null +++ b/examples/async-batch/README.md @@ -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) +``` diff --git a/examples/async-batch/build.gradle.kts b/examples/async-batch/build.gradle.kts new file mode 100644 index 0000000..eaf315c --- /dev/null +++ b/examples/async-batch/build.gradle.kts @@ -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") +} diff --git a/examples/async-batch/src/main/java/io/github/premocloud/typesafe/examples/batch/AsyncBatch.java b/examples/async-batch/src/main/java/io/github/premocloud/typesafe/examples/batch/AsyncBatch.java new file mode 100644 index 0000000..a01764b --- /dev/null +++ b/examples/async-batch/src/main/java/io/github/premocloud/typesafe/examples/batch/AsyncBatch.java @@ -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 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 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> 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())); + } +} diff --git a/examples/async-batch/src/test/java/io/github/premocloud/typesafe/examples/batch/AsyncBatchTest.java b/examples/async-batch/src/test/java/io/github/premocloud/typesafe/examples/batch/AsyncBatchTest.java new file mode 100644 index 0000000..31da352 --- /dev/null +++ b/examples/async-batch/src/test/java/io/github/premocloud/typesafe/examples/batch/AsyncBatchTest.java @@ -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 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()); + } + } + +} diff --git a/examples/build.gradle.kts b/examples/build.gradle.kts new file mode 100644 index 0000000..c33b422 --- /dev/null +++ b/examples/build.gradle.kts @@ -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 { + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + } + + tasks.withType().configureEach { + options.release.set(17) + options.encoding = "UTF-8" + } + + tasks.withType().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") + } + } +} diff --git a/examples/spring-boot/README.md b/examples/spring-boot/README.md new file mode 100644 index 0000000..ced5a45 --- /dev/null +++ b/examples/spring-boot/README.md @@ -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} +``` diff --git a/examples/spring-boot/build.gradle.kts b/examples/spring-boot/build.gradle.kts new file mode 100644 index 0000000..37bf6b9 --- /dev/null +++ b/examples/spring-boot/build.gradle.kts @@ -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") +} diff --git a/examples/spring-boot/src/main/java/io/github/premocloud/typesafe/examples/spring/TicketController.java b/examples/spring-boot/src/main/java/io/github/premocloud/typesafe/examples/spring/TicketController.java new file mode 100644 index 0000000..a763803 --- /dev/null +++ b/examples/spring-boot/src/main/java/io/github/premocloud/typesafe/examples/spring/TicketController.java @@ -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 = 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"; + }; + } +} diff --git a/examples/spring-boot/src/main/java/io/github/premocloud/typesafe/examples/spring/TicketRoutingApplication.java b/examples/spring-boot/src/main/java/io/github/premocloud/typesafe/examples/spring/TicketRoutingApplication.java new file mode 100644 index 0000000..9876106 --- /dev/null +++ b/examples/spring-boot/src/main/java/io/github/premocloud/typesafe/examples/spring/TicketRoutingApplication.java @@ -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); + } +} diff --git a/examples/spring-boot/src/main/java/io/github/premocloud/typesafe/examples/spring/TypeSafeErrorHandler.java b/examples/spring-boot/src/main/java/io/github/premocloud/typesafe/examples/spring/TypeSafeErrorHandler.java new file mode 100644 index 0000000..cee6e51 --- /dev/null +++ b/examples/spring-boot/src/main/java/io/github/premocloud/typesafe/examples/spring/TypeSafeErrorHandler.java @@ -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; + } +} diff --git a/examples/spring-boot/src/main/resources/application.properties b/examples/spring-boot/src/main/resources/application.properties new file mode 100644 index 0000000..e960788 --- /dev/null +++ b/examples/spring-boot/src/main/resources/application.properties @@ -0,0 +1 @@ +typesafe.api-key=${TYPESAFE_API_KEY:} diff --git a/examples/spring-boot/src/test/java/io/github/premocloud/typesafe/examples/spring/TicketControllerTest.java b/examples/spring-boot/src/test/java/io/github/premocloud/typesafe/examples/spring/TicketControllerTest.java new file mode 100644 index 0000000..c89d35c --- /dev/null +++ b/examples/spring-boot/src/test/java/io/github/premocloud/typesafe/examples/spring/TicketControllerTest.java @@ -0,0 +1,98 @@ +package io.github.premocloud.typesafe.examples.spring; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.premocloud.typesafe.examples.spring.TicketController.Route; +import io.github.premocloud.typesafe.examples.spring.TicketController.Team; +import io.github.premocloud.typesafe.examples.spring.TicketController.Ticket; +import io.github.premocloud.typesafe.examples.support.StubTypeSafeServer; +import io.github.premocloud.typesafe.examples.support.StubTypeSafeServer.Request; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +@SpringBootTest(webEnvironment = RANDOM_PORT) +class TicketControllerTest { + + static final String REPLY = """ + { + "model": "jev-1.13.0", + "answers": { + "team": { + "type": "choice", + "choice": "BILLING", + "probabilities": {"BILLING": 0.91, "TECH_SUPPORT": 0.06, "SALES": 0.03}, + "confidence": 0.88 + }, + "escalate": {"type": "noul", "noul": 0.82} + }, + "usage": {"input_tokens": 120, "output_tokens": 8} + } + """; + + static StubTypeSafeServer stub; + + @DynamicPropertySource + static void useStub(DynamicPropertyRegistry registry) throws IOException { + stub = new StubTypeSafeServer(); + registry.add("typesafe.base-url", stub::baseUrl); + registry.add("typesafe.api-key", () -> "test-key"); + } + + @AfterAll + static void stopStub() { + stub.close(); + } + + @Autowired + TestRestTemplate http; + + final ObjectMapper json = new ObjectMapper(); + final Ticket ticket = new Ticket("Charged twice", "My card was billed twice for March. Refund it today or I cancel."); + + @BeforeEach + void forgetEarlierRequests() { + stub.requests().clear(); + } + + @Test + void routesTicketFromAnswers() throws IOException { + stub.reply("/v1/systemone", REPLY); + + Route route = http.postForObject("/tickets/route", ticket, Route.class); + + assertEquals(new Route(Team.BILLING, "finance", true), route); + assertEquals(1, stub.requests().size()); + Request sent = stub.requests().get(0); + assertEquals("/v1/systemone", sent.path()); + JsonNode body = json.readTree(sent.body()); + assertEquals("Charged twice", body.at("/state/ticket/subject").asText()); + assertEquals("choice", body.at("/questions/team/type").asText()); + assertEquals("noul", body.at("/questions/escalate/type").asText()); + } + + @Test + void apiErrorBecomes502() { + stub.reply("/v1/systemone", 402, """ + {"error": "Insufficient credits"} + """); + + ResponseEntity response = http.postForEntity("/tickets/route", ticket, ProblemDetail.class); + + assertEquals(HttpStatus.BAD_GATEWAY, response.getStatusCode()); + assertEquals("402 Insufficient credits", response.getBody().getDetail()); + } +} diff --git a/examples/support/build.gradle.kts b/examples/support/build.gradle.kts new file mode 100644 index 0000000..095a55a --- /dev/null +++ b/examples/support/build.gradle.kts @@ -0,0 +1,6 @@ +// Test support shared by the examples: an in-process stand-in for api.typesafe.ai. +apply(plugin = "java-library") + +dependencies { + "api"(project(":typesafe-sdk")) +} diff --git a/examples/support/src/main/java/io/github/premocloud/typesafe/examples/support/StubTypeSafeServer.java b/examples/support/src/main/java/io/github/premocloud/typesafe/examples/support/StubTypeSafeServer.java new file mode 100644 index 0000000..eba7b1f --- /dev/null +++ b/examples/support/src/main/java/io/github/premocloud/typesafe/examples/support/StubTypeSafeServer.java @@ -0,0 +1,75 @@ +package io.github.premocloud.typesafe.examples.support; + +import com.sun.net.httpserver.HttpServer; +import io.github.premocloud.typesafe.RetryPolicy; +import io.github.premocloud.typesafe.TypeSafeClient; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** In-process stand-in for api.typesafe.ai: answers each path with a fixed reply and records every request. */ +public final class StubTypeSafeServer implements AutoCloseable { + + public record Request(String method, String path, String body) { + } + + private record Reply(int status, String body) { + } + + private final HttpServer server; + private final Map replies = new ConcurrentHashMap<>(); + private final List requests = new CopyOnWriteArrayList<>(); + + public StubTypeSafeServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + String path = exchange.getRequestURI().getPath(); + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + requests.add(new Request(exchange.getRequestMethod(), path, body)); + Reply reply = replies.getOrDefault(path, new Reply(404, "{\"error\":\"no reply for " + path + "\"}")); + + byte[] bytes = reply.body().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(reply.status(), bytes.length); + + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + }); + server.start(); + } + + /** Replies 200 with {@code json} to every request for {@code path}, e.g. {@code /v1/systemone}. */ + public StubTypeSafeServer reply(String path, String json) { + return reply(path, 200, json); + } + + public StubTypeSafeServer reply(String path, int status, String json) { + replies.put(path, new Reply(status, json)); + return this; + } + + public String baseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + /** A client pointed at this server, with retries off so an error reply surfaces at once. */ + public TypeSafeClient client() { + return TypeSafeClient.builder().apiKey("test-key").baseUrl(baseUrl()).retryPolicy(RetryPolicy.none()).build(); + } + + public List requests() { + return requests; + } + + @Override + public void close() { + server.stop(0); + } +} diff --git a/examples/ticket-triage/README.md b/examples/ticket-triage/README.md new file mode 100644 index 0000000..b309345 --- /dev/null +++ b/examples/ticket-triage/README.md @@ -0,0 +1,16 @@ +# Ticket triage + +Jev answers four keyed questions about one support ticket in a single call; a port of the JavaScript SDK's demo. + +```sh +TYPESAFE_API_KEY=your-key ./gradlew :examples:ticket-triage:run +``` + +``` +Available models: jev-latest, jev-preview +billing? 99% +tone frustrated (100%) +urgency 2.07 (today) +refund risk 1.90 (likely) +tokens 465 in / 92 out +``` diff --git a/examples/ticket-triage/build.gradle.kts b/examples/ticket-triage/build.gradle.kts new file mode 100644 index 0000000..8fc7914 --- /dev/null +++ b/examples/ticket-triage/build.gradle.kts @@ -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.triage.TicketTriage") +} diff --git a/examples/ticket-triage/src/main/java/io/github/premocloud/typesafe/examples/triage/TicketTriage.java b/examples/ticket-triage/src/main/java/io/github/premocloud/typesafe/examples/triage/TicketTriage.java new file mode 100644 index 0000000..4d3f23c --- /dev/null +++ b/examples/ticket-triage/src/main/java/io/github/premocloud/typesafe/examples/triage/TicketTriage.java @@ -0,0 +1,57 @@ +package io.github.premocloud.typesafe.examples.triage; + +import io.github.premocloud.typesafe.Choice; +import io.github.premocloud.typesafe.ChoiceAnswer; +import io.github.premocloud.typesafe.ModelCard; +import io.github.premocloud.typesafe.Noul; +import io.github.premocloud.typesafe.Score; +import io.github.premocloud.typesafe.ScoreAnswer; +import io.github.premocloud.typesafe.TypeSafeApiException; +import io.github.premocloud.typesafe.TypeSafeClient; +import io.github.premocloud.typesafe.TypeSafeResponse; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** Triages one support ticket in a single call: is it about billing, the customer's tone, urgency, and refund risk. */ +public final class TicketTriage { + + record Ticket(String subject, String body) { + } + + static final Ticket TICKET = new Ticket( + "Charged twice this month", + "Hi, I see two charges of $49 on my card for August. I only have one account. Please fix this ASAP, I'm pretty frustrated."); + + public static void main(String[] args) { + run(TypeSafeClient.fromEnvironment()); + } + + static void run(TypeSafeClient client) { + try { + List models = client.models().list(); + System.out.println("Available models: " + models.stream().map(ModelCard::name).collect(Collectors.joining(", "))); + + TypeSafeResponse response = client.systemOne(Map.of("ticket", TICKET), Map.of( + "is_billing", Noul.of("Is `ticket` about billing?"), + "tone", Choice.of("What is the customer's tone in `ticket`?", "calm", "frustrated", "angry"), + "urgency", Score.of("How urgent is `ticket`?", "can wait", "this week", "today", "right now"), + "refund_risk", Score.of("How likely is the customer in `ticket` to demand a refund?", "unlikely", "possible", "likely"))); + + ChoiceAnswer tone = response.choice("tone"); + + System.out.printf("billing? %.0f%%%n", response.noul("is_billing") * 100); + System.out.printf("tone %s (%.0f%%)%n", tone.choice(), tone.probabilities().get(tone.choice()) * 100); + System.out.printf("urgency %s%n", level(response.score("urgency"))); + System.out.printf("refund risk %s%n", level(response.score("refund_risk"))); + System.out.printf("tokens %d in / %d out%n", response.usage().inputTokens(), response.usage().outputTokens()); + } catch (TypeSafeApiException e) { + System.err.println("TypeSafe error: " + e.getMessage() + e.requestId().map(id -> " (request " + id + ")").orElse("")); + } + } + + static String level(ScoreAnswer s) { + return "%.2f (%s)".formatted(s.score(), s.legend().get(String.valueOf(Math.round(s.score())))); + } +} diff --git a/examples/ticket-triage/src/test/java/io/github/premocloud/typesafe/examples/triage/TicketTriageTest.java b/examples/ticket-triage/src/test/java/io/github/premocloud/typesafe/examples/triage/TicketTriageTest.java new file mode 100644 index 0000000..a217e0d --- /dev/null +++ b/examples/ticket-triage/src/test/java/io/github/premocloud/typesafe/examples/triage/TicketTriageTest.java @@ -0,0 +1,71 @@ +package io.github.premocloud.typesafe.examples.triage; + +import com.fasterxml.jackson.databind.JsonNode; +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.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class TicketTriageTest { + + static final String MODELS = """ + {"models": [{"name": "jev-1.13.0", "description": "Jev", "release_date": "2026-09-01"}]} + """; + + static final String REPLY = """ + { + "model": "jev-1.13.0", + "answers": { + "is_billing": {"type": "noul", "noul": 0.97}, + "tone": {"type": "choice", "choice": "frustrated", "confidence": 0.80, + "probabilities": {"calm": 0.05, "frustrated": 0.80, "angry": 0.15}}, + "urgency": {"type": "score", "score": 2.3, "confidence": 0.60, + "probabilities": {"0": 0.0, "1": 0.1, "2": 0.5, "3": 0.4}, + "legend": {"0": "can wait", "1": "this week", "2": "today", "3": "right now"}}, + "refund_risk": {"type": "score", "score": 1.4, "confidence": 0.55, + "probabilities": {"0": 0.1, "1": 0.4, "2": 0.5}, + "legend": {"0": "unlikely", "1": "possible", "2": "likely"}} + }, + "usage": {"input_tokens": 312, "output_tokens": 48} + } + """; + + @Test + void listsModelsThenAsksAllFourQuestionsInOneCall() throws IOException { + try (StubTypeSafeServer stub = new StubTypeSafeServer().reply("/v1/models", MODELS).reply("/v1/systemone", REPLY)) { + TicketTriage.run(stub.client()); + + List requests = stub.requests(); + assertEquals(List.of("/v1/models", "/v1/systemone"), requests.stream().map(Request::path).toList()); + + JsonNode body = new ObjectMapper().readTree(requests.get(1).body()); + assertEquals("Charged twice this month", body.at("/state/ticket/subject").asText()); + assertEquals("noul", body.at("/questions/is_billing/type").asText()); + assertEquals("choice", body.at("/questions/tone/type").asText()); + assertEquals("score", body.at("/questions/urgency/type").asText()); + assertEquals("score", body.at("/questions/refund_risk/type").asText()); + } + } + + @Test + void reportsABadKeyOnTheModelsCallInsteadOfCrashing() throws IOException { + try (StubTypeSafeServer stub = new StubTypeSafeServer().reply("/v1/models", 401, "{\"error\": \"invalid api key\"}")) { + assertDoesNotThrow(() -> TicketTriage.run(stub.client())); + + assertEquals(List.of("/v1/models"), stub.requests().stream().map(Request::path).toList()); + } + } + + @Test + void reportsAnApiErrorOnTheTriageCallInsteadOfCrashing() throws IOException { + try (StubTypeSafeServer stub = new StubTypeSafeServer().reply("/v1/models", MODELS).reply("/v1/systemone", 500, "{\"error\": \"upstream unavailable\"}")) { + assertDoesNotThrow(() -> TicketTriage.run(stub.client())); + } + } +} diff --git a/examples/typed-routing/README.md b/examples/typed-routing/README.md new file mode 100644 index 0000000..8b5215e --- /dev/null +++ b/examples/typed-routing/README.md @@ -0,0 +1,15 @@ +# Typed routing + +Routes tickets with typed `Ask`s: Jev's answer is a `Team`, so an exhaustive `switch` picks the queue. + +```sh +TYPESAFE_API_KEY=your-key ./gradlew :examples:typed-routing:run +``` + +``` +ticket team prob queue escalate +I was charged twice this month. Refund it today or I dispute it. BILLING 100% finance yes +The export button has done nothing since yesterday's update. TECH_SUPPORT 100% helpdesk no +We're a 40-person team evaluating Pro. Any volume discount? SALES 100% sales no +How do I update the card on file? BILLING 100% finance no +``` diff --git a/examples/typed-routing/build.gradle.kts b/examples/typed-routing/build.gradle.kts new file mode 100644 index 0000000..7bb8474 --- /dev/null +++ b/examples/typed-routing/build.gradle.kts @@ -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.routing.TypedRouting") +} diff --git a/examples/typed-routing/src/main/java/io/github/premocloud/typesafe/examples/routing/TypedRouting.java b/examples/typed-routing/src/main/java/io/github/premocloud/typesafe/examples/routing/TypedRouting.java new file mode 100644 index 0000000..7e80114 --- /dev/null +++ b/examples/typed-routing/src/main/java/io/github/premocloud/typesafe/examples/routing/TypedRouting.java @@ -0,0 +1,74 @@ +package io.github.premocloud.typesafe.examples.routing; + +import io.github.premocloud.typesafe.Ask; +import io.github.premocloud.typesafe.ChoiceAnswer; +import io.github.premocloud.typesafe.ChoiceAsk; +import io.github.premocloud.typesafe.NoulAsk; +import io.github.premocloud.typesafe.TypeSafeApiException; +import io.github.premocloud.typesafe.TypeSafeClient; +import io.github.premocloud.typesafe.TypeSafeResponse; + +import java.util.List; +import java.util.Map; + +/** Routes support tickets through Asks declared once: the answer is a {@code Team}, so the queue is a switch. */ +public final class TypedRouting { + + enum Team { BILLING, TECH_SUPPORT, SALES } + + static final ChoiceAsk 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")); + + static final List TICKETS = List.of( + "I was charged twice this month. Refund it today or I dispute it.", + "The export button has done nothing since yesterday's update.", + "We're a 40-person team evaluating Pro. Any volume discount?", + "How do I update the card on file?"); + + static final String COLUMNS = "%-64s %-12s %4s %-8s %s%n"; + + public static void main(String[] args) { + run(TypeSafeClient.fromEnvironment()); + } + + static void run(TypeSafeClient client) { + System.out.printf(COLUMNS, "ticket", "team", "prob", "queue", "escalate"); + + for (String ticket : TICKETS) { + try { + TypeSafeResponse response = client.systemOne(Map.of("ticket", ticket), TEAM, ESCALATE); + + ChoiceAnswer team = response.answer(TEAM); + boolean escalate = response.answer(ESCALATE).noul() > 0.5; + + System.out.printf(COLUMNS, ticket, team.choice(), + "%.0f%%".formatted(team.probabilities().get(team.choice()) * 100), + queue(team.choice()), escalate ? "yes" : "no"); + } catch (TypeSafeApiException e) { + System.out.println("TypeSafe error: " + e.getMessage() + + e.requestId().map(id -> " (request " + id + ")").orElse("")); + } + } + } + + /** Add a Team and this stops compiling until the new constant has a queue. */ + static String queue(Team team) { + return switch (team) { + case BILLING -> "finance"; + case TECH_SUPPORT -> "helpdesk"; + case SALES -> "sales"; + }; + } +} diff --git a/examples/typed-routing/src/test/java/io/github/premocloud/typesafe/examples/routing/TypedRoutingTest.java b/examples/typed-routing/src/test/java/io/github/premocloud/typesafe/examples/routing/TypedRoutingTest.java new file mode 100644 index 0000000..1355fa3 --- /dev/null +++ b/examples/typed-routing/src/test/java/io/github/premocloud/typesafe/examples/routing/TypedRoutingTest.java @@ -0,0 +1,48 @@ +package io.github.premocloud.typesafe.examples.routing; + +import com.fasterxml.jackson.databind.JsonNode; +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 static org.junit.jupiter.api.Assertions.assertEquals; + +class TypedRoutingTest { + + static final String REPLY = """ + { + "model": "jev-1.13.0", + "answers": { + "team": { + "type": "choice", + "choice": "BILLING", + "probabilities": {"BILLING": 0.91, "TECH_SUPPORT": 0.06, "SALES": 0.03}, + "confidence": 0.88 + }, + "escalate": {"type": "noul", "noul": 0.82} + }, + "usage": {"input_tokens": 120, "output_tokens": 8} + } + """; + + @Test + void asksBothQuestionsOncePerTicket() throws IOException { + try (StubTypeSafeServer stub = new StubTypeSafeServer().reply("/v1/systemone", REPLY)) { + TypedRouting.run(stub.client()); + + assertEquals(TypedRouting.TICKETS.size(), stub.requests().size()); + + for (Request request : stub.requests()) { + assertEquals("/v1/systemone", request.path()); + + JsonNode sent = new ObjectMapper().readTree(request.body()); + assertEquals("choice", sent.at("/questions/team/type").asText()); + assertEquals("noul", sent.at("/questions/escalate/type").asText()); + assertEquals(TypedRouting.Team.values().length, sent.at("/questions/team/criteria").size()); + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 1f82241..32f7a30 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,11 @@ rootProject.name = "typesafe-sdk-java" include("typesafe-sdk", "typesafe-sdk-spring-boot-starter") + +include( + ":examples:support", + ":examples:ticket-triage", + ":examples:typed-routing", + ":examples:async-batch", + ":examples:spring-boot", +)