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", +)