diff --git a/braintrust-sdk/src/main/java/dev/braintrust/Braintrust.java b/braintrust-sdk/src/main/java/dev/braintrust/Braintrust.java index 01bfda81..9e44b855 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/Braintrust.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/Braintrust.java @@ -14,6 +14,7 @@ import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder; import java.net.URI; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.Getter; @@ -171,16 +172,150 @@ public Eval.Builder evalBuilder() { Eval.builder().config(this.config).apiClient(this.openApiClient); } + /** + * Fetch the latest version of a dataset from Braintrust, using the default project from + * configuration. + * + * @deprecated the {@code INPUT} and {@code OUTPUT} type parameters are not applied at runtime: + * case values are returned as raw JSON-decoded objects (e.g. {@code LinkedHashMap}), and + * accessing them as {@code INPUT}/{@code OUTPUT} will throw {@link ClassCastException} + * unless those types are {@code Object} or {@code Map}. Use {@link #fetchDataset(String, + * Class, Class)} instead. + */ + @Deprecated public Dataset fetchDataset(String datasetName) { return fetchDataset(datasetName, null); } + /** + * Fetch the latest version of a dataset from Braintrust, deserializing each case's {@code + * input} and {@code expected} values into the given types. + * + *

Values are deserialized using the SDK's shared Jackson mapper (see {@link + * dev.braintrust.json.BraintrustJsonMapper}), which may be customized via {@link + * dev.braintrust.json.BraintrustJsonMapper#configure}. For full control over per-value + * conversion (custom mappers, fixups, generic types), use {@link #fetchDataset(String, + * Function, Function)}. + * + *

The returned dataset preserves experiment-to-dataset linking when used with {@link Eval}: + * experiments created by {@code Eval.run()} are stamped with this dataset's id and version. + * + * @param datasetName the name of the dataset within the configured project + * @param inputClass the type to deserialize each case's {@code input} into + * @param outputClass the type to deserialize each case's {@code expected} into + * @return a typed view of the remote dataset; cases are fetched lazily, and deserialization + * errors surface during iteration + */ + public Dataset fetchDataset( + String datasetName, Class inputClass, Class outputClass) { + return fetchDataset(datasetName, null, inputClass, outputClass); + } + + /** + * Fetch a specific version of a dataset from Braintrust, deserializing each case's {@code + * input} and {@code expected} values into the given types. + * + *

See {@link #fetchDataset(String, Class, Class)} for deserialization and experiment-linking + * semantics. + * + * @param datasetName the name of the dataset within the configured project + * @param datasetVersion the dataset version to pin, or null to fetch the latest version upon + * every cursor open + * @param inputClass the type to deserialize each case's {@code input} into + * @param outputClass the type to deserialize each case's {@code expected} into + * @return a typed view of the remote dataset; cases are fetched lazily, and deserialization + * errors surface during iteration + */ + public Dataset fetchDataset( + String datasetName, + @Nullable String datasetVersion, + Class inputClass, + Class outputClass) { + return Dataset.fetchFromBraintrust( + openApiClient, + resolveProjectName(), + datasetName, + datasetVersion, + inputClass, + outputClass); + } + + /** + * Fetch a specific version of a dataset from Braintrust, using the default project from + * configuration. + * + * @deprecated the {@code INPUT} and {@code OUTPUT} type parameters are not applied at runtime; + * see {@link #fetchDataset(String)}. Use {@link #fetchDataset(String, String, Class, + * Class)} instead. + */ + @Deprecated public Dataset fetchDataset( String datasetName, @Nullable String datasetVersion) { return Dataset.fetchFromBraintrust( openApiClient, resolveProjectName(), datasetName, datasetVersion); } + /** + * Fetch the latest version of a dataset from Braintrust, converting each case's {@code input} + * and {@code expected} values with the supplied converter functions. + * + *

Converters receive the raw JSON-decoded value (typically a {@code Map}, + * {@code List}, {@code String}, {@code Number}, {@code Boolean}, or null) and are responsible + * for producing the typed value. This gives the caller full control over deserialization — e.g. + * a custom Jackson {@code ObjectMapper}, generic types, or row fixups: + * + *

{@code
+     * Dataset ds = braintrust.fetchDataset(
+     *         "golden-cases",
+     *         raw -> myMapper.convertValue(raw, MyInput.class),
+     *         raw -> myMapper.convertValue(raw, MyOutput.class));
+     * }
+ * + *

The returned dataset preserves experiment-to-dataset linking when used with {@link Eval}: + * experiments created by {@code Eval.run()} are stamped with this dataset's id and version. + * + * @param datasetName the name of the dataset within the configured project + * @param inputConverter converts each case's raw {@code input} value; must tolerate null + * @param outputConverter converts each case's raw {@code expected} value; must tolerate null + * @return a typed view of the remote dataset; cases are fetched lazily, and converter errors + * surface during iteration + */ + public Dataset fetchDataset( + String datasetName, + Function inputConverter, + Function outputConverter) { + return fetchDataset(datasetName, null, inputConverter, outputConverter); + } + + /** + * Fetch a specific version of a dataset from Braintrust, converting each case's {@code input} + * and {@code expected} values with the supplied converter functions. + * + *

See {@link #fetchDataset(String, Function, Function)} for converter and experiment-linking + * semantics. + * + * @param datasetName the name of the dataset within the configured project + * @param datasetVersion the dataset version to pin, or null to fetch the latest version upon + * every cursor open + * @param inputConverter converts each case's raw {@code input} value; must tolerate null + * @param outputConverter converts each case's raw {@code expected} value; must tolerate null + * @return a typed view of the remote dataset; cases are fetched lazily, and converter errors + * surface during iteration + */ + public Dataset fetchDataset( + String datasetName, + @Nullable String datasetVersion, + Function inputConverter, + Function outputConverter) { + return Dataset.fetchFromBraintrust( + openApiClient, + resolveProjectName(), + datasetName, + datasetVersion, + inputConverter, + outputConverter); + } + /** * Fetch a scorer from Braintrust by slug, using the default project from configuration. * @@ -203,6 +338,30 @@ public Scorer fetchScorer( return Scorer.fetchFromBraintrust(openApiClient, resolveProjectName(), scorerSlug, version); } + /** + * Fetch a scorer from Braintrust by slug, using the default project from configuration, with a + * custom converter for serializing scorer argument values. + * + *

By default, argument values ({@code input}, {@code output}, {@code expected}, {@code + * metadata}, {@code parameters}) are serialized with the SDK's shared Jackson mapper (see + * {@link dev.braintrust.json.BraintrustJsonMapper}). Use this variant to control serialization, + * e.g. with a custom {@code ObjectMapper}: {@code fetchScorer(slug, null, + * myMapper::valueToTree)}. + * + * @param scorerSlug the unique slug identifier for the scorer + * @param version optional version of the scorer to fetch + * @param argumentConverter converts each argument value into a JSON-serializable form (e.g. a + * Jackson {@code JsonNode}, {@code Map}, or scalar); must tolerate null + * @return a Scorer that invokes the remote function + */ + public Scorer fetchScorer( + String scorerSlug, + @Nullable String version, + Function argumentConverter) { + return Scorer.fetchFromBraintrust( + openApiClient, resolveProjectName(), scorerSlug, version, argumentConverter); + } + /** * Fetch a scorer from Braintrust by project name and slug. * @@ -216,6 +375,27 @@ public Scorer fetchScorer( return Scorer.fetchFromBraintrust(openApiClient, projectName, scorerSlug, version); } + /** + * Fetch a scorer from Braintrust by project name and slug, with a custom converter for + * serializing scorer argument values. See {@link #fetchScorer(String, String, Function)} for + * converter semantics. + * + * @param projectName the name of the project containing the scorer + * @param scorerSlug the unique slug identifier for the scorer + * @param version optional version of the scorer to fetch + * @param argumentConverter converts each argument value into a JSON-serializable form; must + * tolerate null + * @return a Scorer that invokes the remote function + */ + public Scorer fetchScorer( + String projectName, + String scorerSlug, + @Nullable String version, + Function argumentConverter) { + return Scorer.fetchFromBraintrust( + openApiClient, projectName, scorerSlug, version, argumentConverter); + } + /** * Resolve the default project name from config. If only a project ID is configured, looks it up * via the API. Mirrors the behavior of the old hand-rolled client. diff --git a/braintrust-sdk/src/main/java/dev/braintrust/devserver/Devserver.java b/braintrust-sdk/src/main/java/dev/braintrust/devserver/Devserver.java index 5a5ba9c9..1e41189f 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/devserver/Devserver.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/devserver/Devserver.java @@ -347,7 +347,7 @@ private void handleEval(HttpExchange exchange) throws IOException { @SuppressWarnings({"unchecked", "rawtypes"}) private void handleStreamingEval( HttpExchange exchange, - RemoteEval eval, + RemoteEval eval, EvalRequest request, RequestContext context, List> remoteScorers) @@ -418,11 +418,13 @@ private void handleStreamingEval( // NOTE: this code is serial but written in a thread-safe manner to support // concurrent dataset fetching and eval execution - extractDataset(request, apiClient) + extractDataset( + request, + apiClient, + eval.getInputConverter(), + eval.getOutputConverter()) .forEach( - rawDataset -> { - final DatasetCase datasetCase = - (DatasetCase) rawDataset; + datasetCase -> { var evalSpan = tracer.spanBuilder("eval") .setNoParent() @@ -630,7 +632,12 @@ private void handleExperimentSnapshot( .config(braintrust.config()) .apiClient(apiClient) .projectId(projectId) - .dataset((Dataset) extractDataset(request, apiClient)) + .dataset( + extractDataset( + request, + apiClient, + eval.getInputConverter(), + eval.getOutputConverter())) .task(eval.getTask()) .scorers(allScorers.toArray(new Scorer[0])) .parameters(eval.getParameters()) @@ -1283,23 +1290,28 @@ private static Optional extractPlaygroundParent(EvalRequest request) * @throws IllegalStateException if no dataset specification is provided * @throws IllegalArgumentException if dataset or project is not found */ - private static Dataset extractDataset( - EvalRequest request, BraintrustOpenApiClient apiClient) { + private static Dataset extractDataset( + EvalRequest request, + BraintrustOpenApiClient apiClient, + java.util.function.Function inputConverter, + java.util.function.Function outputConverter) { EvalRequest.DataSpec dataSpec = request.getData(); if (dataSpec.getData() != null && !dataSpec.getData().isEmpty()) { // Method 1: Inline data - List cases = new ArrayList<>(); + List> cases = new ArrayList<>(); for (EvalRequest.EvalCaseData caseData : dataSpec.getData()) { - DatasetCase datasetCase = + DatasetCase datasetCase = DatasetCase.of( - caseData.getInput(), - caseData.getExpected(), + inputConverter.apply(caseData.getInput()), + outputConverter.apply(caseData.getExpected()), caseData.getTags() != null ? caseData.getTags() : List.of(), caseData.getMetadata() != null ? caseData.getMetadata() : Map.of()); cases.add(datasetCase); } - return Dataset.of(cases.toArray(new DatasetCase[0])); + @SuppressWarnings("unchecked") + DatasetCase[] caseArray = cases.toArray(new DatasetCase[0]); + return Dataset.of(caseArray); } else if (dataSpec.getProjectName() != null && dataSpec.getDatasetName() != null) { // Method 2: Fetch by project name and dataset name log.debug( @@ -1307,7 +1319,12 @@ private static Optional extractPlaygroundParent(EvalRequest request) dataSpec.getProjectName(), dataSpec.getDatasetName()); return Dataset.fetchFromBraintrust( - apiClient, dataSpec.getProjectName(), dataSpec.getDatasetName(), null); + apiClient, + dataSpec.getProjectName(), + dataSpec.getDatasetName(), + null, + inputConverter, + outputConverter); } else if (dataSpec.getDatasetId() != null) { // Method 3: Fetch by dataset ID log.debug("Fetching dataset from Braintrust by ID: {}", dataSpec.getDatasetId()); @@ -1325,7 +1342,12 @@ private static Optional extractPlaygroundParent(EvalRequest request) fetchedDatasetName); return Dataset.fetchFromBraintrust( - apiClient, fetchedProjectName, fetchedDatasetName, null); + apiClient, + fetchedProjectName, + fetchedDatasetName, + null, + inputConverter, + outputConverter); } else { throw new IllegalStateException("No dataset specification provided"); } diff --git a/braintrust-sdk/src/main/java/dev/braintrust/devserver/RemoteEval.java b/braintrust-sdk/src/main/java/dev/braintrust/devserver/RemoteEval.java index 9a2075c6..5293ca83 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/devserver/RemoteEval.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/devserver/RemoteEval.java @@ -6,6 +6,7 @@ import dev.braintrust.eval.Scorer; import dev.braintrust.eval.Task; import dev.braintrust.eval.TaskResult; +import dev.braintrust.json.BraintrustJsonMapper; import java.util.*; import java.util.function.Function; import javax.annotation.Nonnull; @@ -42,6 +43,71 @@ public class RemoteEval { /** Optional parameter definitions that can be configured from the UI */ @Singular @Nonnull private final List> parameters; + /** Converts raw JSON-decoded {@code input} values from eval requests into {@code INPUT}. */ + @Nonnull private final Function inputConverter; + + /** Converts raw JSON-decoded {@code expected} values from eval requests into {@code OUTPUT}. */ + @Nonnull private final Function outputConverter; + + /** + * Create a builder for an eval whose case {@code input} and {@code expected} values are + * deserialized into the given types using the SDK's shared Jackson mapper (see {@link + * dev.braintrust.json.BraintrustJsonMapper}). + * + *

{@code
+     * var eval = RemoteEval.builder(MyInput.class, MyOutput.class)
+     *         .name("my-eval")
+     *         .taskFunction(input -> ...)
+     *         .build();
+     * }
+ * + * @param inputClass the type to deserialize each case's {@code input} into + * @param outputClass the type to deserialize each case's {@code expected} into + */ + public static Builder builder( + Class inputClass, Class outputClass) { + return builder( + BraintrustJsonMapper.converter(inputClass), + BraintrustJsonMapper.converter(outputClass)); + } + + /** + * Create a builder for an eval whose case {@code input} and {@code expected} values are + * converted with the supplied functions. Converters receive the raw JSON-decoded value + * (typically a {@code Map}, {@code List}, {@code String}, {@code Number}, + * {@code Boolean}, or null) and must tolerate null. Most callers should prefer {@link + * #builder(Class, Class)}; use this variant for generic types or custom deserialization. + * + * @param inputConverter converts each case's raw {@code input} value + * @param outputConverter converts each case's raw {@code expected} value + */ + public static Builder builder( + Function inputConverter, Function outputConverter) { + return new Builder() + .inputConverter(Objects.requireNonNull(inputConverter)) + .outputConverter(Objects.requireNonNull(outputConverter)); + } + + /** + * Create a builder with no input/expected deserialization: case values are passed through as + * raw JSON-decoded objects (e.g. {@code LinkedHashMap}). + * + * @deprecated the {@code INPUT} and {@code OUTPUT} type parameters are not applied at runtime, + * and the task/scorers will receive raw JSON objects, throwing {@link ClassCastException} + * unless those types are {@code Object} or {@code Map}. Use {@link #builder(Class, Class)} + * or {@link #builder(Function, Function)} instead. + */ + @Deprecated + public static Builder builder() { + return RemoteEval.builder(uncheckedCast(), uncheckedCast()); + } + + /** Legacy behavior: pass the raw JSON-decoded value through via an unchecked cast. */ + @SuppressWarnings("unchecked") + private static Function uncheckedCast() { + return raw -> (T) raw; + } + public static class Builder { /** * Convenience builder method to create a RemoteEval with a simple task function. @@ -65,6 +131,8 @@ public TaskResult apply( /** Build the RemoteEval */ public RemoteEval build() { var result = internalBuild(); + Objects.requireNonNull(result.getInputConverter(), "inputConverter"); + Objects.requireNonNull(result.getOutputConverter(), "outputConverter"); // Validate parameter names are unique var seen = new HashSet(); for (var param : result.getParameters()) { diff --git a/braintrust-sdk/src/main/java/dev/braintrust/eval/Dataset.java b/braintrust-sdk/src/main/java/dev/braintrust/eval/Dataset.java index ee42952b..671ff612 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/eval/Dataset.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/eval/Dataset.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Optional; import java.util.function.Consumer; +import java.util.function.Function; import javax.annotation.Nullable; import javax.annotation.concurrent.NotThreadSafe; @@ -74,11 +75,77 @@ static Dataset fetchFromBraintrust( apiClient.openApiClient(), projectName, datasetName, datasetVersion); } + /** + * Fetch a dataset from Braintrust. + * + * @deprecated the {@code INPUT} and {@code OUTPUT} type parameters are not applied at runtime: + * case values are returned as raw JSON-decoded objects (e.g. {@code LinkedHashMap}). Use + * {@link #fetchFromBraintrust(BraintrustOpenApiClient, String, String, String, Class, + * Class)} instead. + */ + @Deprecated static Dataset fetchFromBraintrust( BraintrustOpenApiClient apiClient, String projectName, String datasetName, @Nullable String datasetVersion) { + var datasetId = resolveDatasetId(apiClient, projectName, datasetName); + return new DatasetBrainstoreImpl<>(apiClient, datasetId, datasetVersion); + } + + /** + * Fetch a dataset from Braintrust, deserializing each case's {@code input} and {@code expected} + * values into the given types using the SDK's shared Jackson mapper (see {@link + * dev.braintrust.json.BraintrustJsonMapper}). + * + * @param apiClient the Braintrust API client + * @param projectName the project containing the dataset + * @param datasetName the name of the dataset within the project + * @param datasetVersion the dataset version to pin, or null to fetch the latest version upon + * every cursor open + * @param inputClass the type to deserialize each case's {@code input} into + * @param outputClass the type to deserialize each case's {@code expected} into + */ + static Dataset fetchFromBraintrust( + BraintrustOpenApiClient apiClient, + String projectName, + String datasetName, + @Nullable String datasetVersion, + Class inputClass, + Class outputClass) { + var datasetId = resolveDatasetId(apiClient, projectName, datasetName); + return new DatasetBrainstoreImpl<>( + apiClient, datasetId, datasetVersion, inputClass, outputClass); + } + + /** + * Fetch a dataset from Braintrust, converting each case's {@code input} and {@code expected} + * values with the supplied converter functions. Converters receive the raw JSON-decoded value + * (typically a {@code Map}, {@code List}, {@code String}, {@code Number}, + * {@code Boolean}, or null) and must tolerate null. + * + * @param apiClient the Braintrust API client + * @param projectName the project containing the dataset + * @param datasetName the name of the dataset within the project + * @param datasetVersion the dataset version to pin, or null to fetch the latest version upon + * every cursor open + * @param inputConverter converts each case's raw {@code input} value + * @param outputConverter converts each case's raw {@code expected} value + */ + static Dataset fetchFromBraintrust( + BraintrustOpenApiClient apiClient, + String projectName, + String datasetName, + @Nullable String datasetVersion, + Function inputConverter, + Function outputConverter) { + var datasetId = resolveDatasetId(apiClient, projectName, datasetName); + return new DatasetBrainstoreImpl<>( + apiClient, datasetId, datasetVersion, inputConverter, outputConverter); + } + + private static String resolveDatasetId( + BraintrustOpenApiClient apiClient, String projectName, String datasetName) { var datasetsApi = new DatasetsApi(apiClient); var objects = datasetsApi @@ -101,7 +168,6 @@ static Dataset fetchFromBraintrust( + " datasets"); } - var dataset = objects.get(0); - return new DatasetBrainstoreImpl<>(apiClient, dataset.getId().toString(), datasetVersion); + return objects.get(0).getId().toString(); } } diff --git a/braintrust-sdk/src/main/java/dev/braintrust/eval/DatasetBrainstoreImpl.java b/braintrust-sdk/src/main/java/dev/braintrust/eval/DatasetBrainstoreImpl.java index 60ecfc48..ad1dad88 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/eval/DatasetBrainstoreImpl.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/eval/DatasetBrainstoreImpl.java @@ -2,9 +2,11 @@ import dev.braintrust.api.BraintrustApiClient; import dev.braintrust.api.BraintrustOpenApiClient; +import dev.braintrust.json.BraintrustJsonMapper; import dev.braintrust.openapi.api.DatasetsApi; import dev.braintrust.openapi.model.FetchEventsRequest; import java.util.*; +import java.util.function.Function; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -14,16 +16,72 @@ public class DatasetBrainstoreImpl implements Dataset inputConverter; + private final Function outputConverter; @Deprecated public DatasetBrainstoreImpl( BraintrustApiClient apiClient, String datasetId, @Nullable String datasetVersion) { - this(apiClient.openApiClient(), datasetId, datasetVersion, 512); + this(apiClient.openApiClient(), datasetId, datasetVersion); } + /** + * @deprecated the {@code INPUT} and {@code OUTPUT} type parameters are not applied at runtime: + * case values are returned as raw JSON-decoded objects. Use {@link + * #DatasetBrainstoreImpl(BraintrustOpenApiClient, String, String, Class, Class)} instead. + */ + @Deprecated public DatasetBrainstoreImpl( BraintrustOpenApiClient apiClient, String datasetId, @Nullable String datasetVersion) { - this(apiClient, datasetId, datasetVersion, 512); + this(apiClient, datasetId, datasetVersion, 512, uncheckedCast(), uncheckedCast()); + } + + /** + * Create a dataset whose case {@code input} and {@code expected} values are deserialized into + * the given types using the SDK's shared Jackson mapper (see {@link BraintrustJsonMapper}). + * + * @param apiClient the Braintrust API client + * @param datasetId the Braintrust dataset id + * @param datasetVersion the dataset version to pin, or null to fetch the latest version upon + * every cursor open + * @param inputClass the type to deserialize each case's {@code input} into + * @param outputClass the type to deserialize each case's {@code expected} into + */ + public DatasetBrainstoreImpl( + BraintrustOpenApiClient apiClient, + String datasetId, + @Nullable String datasetVersion, + Class inputClass, + Class outputClass) { + this( + apiClient, + datasetId, + datasetVersion, + 512, + BraintrustJsonMapper.converter(inputClass), + BraintrustJsonMapper.converter(outputClass)); + } + + /** + * Create a dataset whose case {@code input} and {@code expected} values are converted with the + * supplied functions. Converters receive the raw JSON-decoded value (typically a {@code + * Map}, {@code List}, {@code String}, {@code Number}, {@code Boolean}, or + * null). + * + * @param apiClient the Braintrust API client + * @param datasetId the Braintrust dataset id + * @param datasetVersion the dataset version to pin, or null to fetch the latest version upon + * every cursor open + * @param inputConverter converts each case's raw {@code input} value; must tolerate null + * @param outputConverter converts each case's raw {@code expected} value; must tolerate null + */ + public DatasetBrainstoreImpl( + BraintrustOpenApiClient apiClient, + String datasetId, + @Nullable String datasetVersion, + Function inputConverter, + Function outputConverter) { + this(apiClient, datasetId, datasetVersion, 512, inputConverter, outputConverter); } DatasetBrainstoreImpl( @@ -31,10 +89,28 @@ public DatasetBrainstoreImpl( String datasetId, @Nullable String datasetVersion, int batchSize) { + this(apiClient, datasetId, datasetVersion, batchSize, uncheckedCast(), uncheckedCast()); + } + + DatasetBrainstoreImpl( + BraintrustOpenApiClient apiClient, + String datasetId, + @Nullable String datasetVersion, + int batchSize, + Function inputConverter, + Function outputConverter) { this.apiClient = apiClient; this.datasetId = datasetId; this.batchSize = batchSize; this.pinnedVersion = datasetVersion; + this.inputConverter = Objects.requireNonNull(inputConverter); + this.outputConverter = Objects.requireNonNull(outputConverter); + } + + /** Legacy behavior: pass the raw JSON-decoded value through via an unchecked cast. */ + @SuppressWarnings("unchecked") + private static Function uncheckedCast() { + return raw -> (T) raw; } @Override @@ -98,7 +174,6 @@ private class BrainstoreCursor implements Cursor> { } @Override - @SuppressWarnings("unchecked") public Optional> next() { if (closed) { throw new IllegalStateException("Cursor is closed"); @@ -114,8 +189,8 @@ public Optional> next() { var event = currentBatch.get(currentIndex++); - INPUT input = (INPUT) event.getInput(); - OUTPUT expected = (OUTPUT) event.getExpected(); + INPUT input = inputConverter.apply(event.getInput()); + OUTPUT expected = outputConverter.apply(event.getExpected()); var metadataObj = event.getMetadata(); // InsertProjectLogsEventMetadata extends HashMap. Jackson stores diff --git a/braintrust-sdk/src/main/java/dev/braintrust/eval/Scorer.java b/braintrust-sdk/src/main/java/dev/braintrust/eval/Scorer.java index 1b62fa25..02233eb0 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/eval/Scorer.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/eval/Scorer.java @@ -111,6 +111,44 @@ static Scorer fetchFromBraintrust( String projectName, String scorerSlug, @Nullable String version) { + return fetchFromBraintrust( + apiClient, + projectName, + scorerSlug, + version, + ScorerBrainstoreImpl.DEFAULT_ARGUMENT_CONVERTER); + } + + /** + * Fetch a scorer from Braintrust by project name and slug, with a custom converter for + * serializing scorer argument values. + * + *

By default ({@link #fetchFromBraintrust(BraintrustOpenApiClient, String, String, + * String)}), argument values are serialized with the SDK's shared Jackson mapper (see {@link + * dev.braintrust.json.BraintrustJsonMapper}). Use this variant to control serialization, e.g. + * with a custom {@code ObjectMapper}: + * + *

{@code
+     * Scorer scorer = Scorer.fetchFromBraintrust(
+     *         client, project, slug, null, myMapper::valueToTree);
+     * }
+ * + * @param apiClient the API client to use + * @param projectName the name of the project containing the scorer + * @param scorerSlug the unique slug identifier for the scorer + * @param version optional version of the scorer to fetch + * @param argumentConverter converts each user-supplied argument value ({@code input}, {@code + * output}, {@code expected}, {@code metadata}, {@code parameters}) into a JSON-serializable + * form (e.g. a Jackson {@code JsonNode}, {@code Map}, or scalar); must tolerate null + * @return a Scorer that invokes the remote function + * @throws RuntimeException if the scorer is not found + */ + static Scorer fetchFromBraintrust( + BraintrustOpenApiClient apiClient, + String projectName, + String scorerSlug, + @Nullable String version, + Function argumentConverter) { var functionsApi = new FunctionsApi(apiClient); var objects = functionsApi @@ -133,6 +171,7 @@ static Scorer fetchFromBraintrust( "Scorer not found: project=" + projectName + ", slug=" + scorerSlug); } - return new ScorerBrainstoreImpl<>(apiClient, objects.get(0).getId().toString(), version); + return new ScorerBrainstoreImpl<>( + apiClient, objects.get(0).getId().toString(), version, argumentConverter); } } diff --git a/braintrust-sdk/src/main/java/dev/braintrust/eval/ScorerBrainstoreImpl.java b/braintrust-sdk/src/main/java/dev/braintrust/eval/ScorerBrainstoreImpl.java index ec491262..5273bf80 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/eval/ScorerBrainstoreImpl.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/eval/ScorerBrainstoreImpl.java @@ -4,6 +4,7 @@ import dev.braintrust.BraintrustUtils; import dev.braintrust.api.BraintrustApiClient; import dev.braintrust.api.BraintrustOpenApiClient; +import dev.braintrust.json.BraintrustJsonMapper; import dev.braintrust.openapi.JSON; import dev.braintrust.openapi.api.FunctionsApi; import dev.braintrust.openapi.api.ProjectsApi; @@ -39,9 +40,18 @@ public class ScorerBrainstoreImpl implements Scorer { private static final ObjectMapper MAPPER = new JSON().getMapper(); + /** + * Default converter for user-supplied scorer argument values: serializes to a JSON tree using + * the SDK's shared mapper (see {@link BraintrustJsonMapper}), so user serialization rules + * (annotations, custom modules registered via {@link BraintrustJsonMapper#configure}) apply. + */ + public static final java.util.function.Function DEFAULT_ARGUMENT_CONVERTER = + value -> BraintrustJsonMapper.get().valueToTree(value); + private final BraintrustOpenApiClient apiClient; private final String functionId; private final @Nullable String version; + private final java.util.function.Function argumentConverter; private final AtomicReference braintrustFunction = new AtomicReference<>(null); private final AtomicReference scorerName = new AtomicReference<>(null); @@ -52,7 +62,8 @@ public ScorerBrainstoreImpl( } /** - * Create a new remote scorer. + * Create a new remote scorer. Argument values are serialized with {@link + * #DEFAULT_ARGUMENT_CONVERTER}. * * @param apiClient the API client to use for invoking the function * @param functionId braintrust function id @@ -61,9 +72,30 @@ public ScorerBrainstoreImpl( */ public ScorerBrainstoreImpl( BraintrustOpenApiClient apiClient, String functionId, @Nullable String version) { + this(apiClient, functionId, version, DEFAULT_ARGUMENT_CONVERTER); + } + + /** + * Create a new remote scorer with a custom argument converter. + * + * @param apiClient the API client to use for invoking the function + * @param functionId braintrust function id + * @param version optional version of the function to invoke. null always invokes latest + * version. + * @param argumentConverter converts each user-supplied argument value ({@code input}, {@code + * output}, {@code expected}, {@code metadata}, {@code parameters}) into a JSON-serializable + * form (e.g. a Jackson {@code JsonNode}, {@code Map}, or scalar) before the invoke request + * is sent; must tolerate null + */ + public ScorerBrainstoreImpl( + BraintrustOpenApiClient apiClient, + String functionId, + @Nullable String version, + java.util.function.Function argumentConverter) { this.apiClient = apiClient; this.functionId = functionId; this.version = version; + this.argumentConverter = java.util.Objects.requireNonNull(argumentConverter); } @Override @@ -74,13 +106,19 @@ public String getName() { @Override public List score(TaskResult taskResult) { + // Convert user-supplied values (input/output/expected/...) into a JSON-serializable form. + // The default converter serializes with the SDK's shared mapper so user serialization + // rules (annotations, custom modules registered via BraintrustJsonMapper.configure) + // apply. The OpenAPI client's own mapper then writes the converted values verbatim, + // keeping wire-protocol handling of the request envelope unchanged. var scorerArgs = new java.util.LinkedHashMap(); - scorerArgs.put("input", taskResult.datasetCase().input()); - scorerArgs.put("output", taskResult.result()); - scorerArgs.put("expected", taskResult.datasetCase().expected()); - scorerArgs.put("metadata", taskResult.datasetCase().metadata()); + scorerArgs.put("input", argumentConverter.apply(taskResult.datasetCase().input())); + scorerArgs.put("output", argumentConverter.apply(taskResult.result())); + scorerArgs.put("expected", argumentConverter.apply(taskResult.datasetCase().expected())); + scorerArgs.put("metadata", argumentConverter.apply(taskResult.datasetCase().metadata())); if (!taskResult.parameters().isEmpty()) { - scorerArgs.put("parameters", taskResult.parameters().getMerged()); + scorerArgs.put( + "parameters", argumentConverter.apply(taskResult.parameters().getMerged())); } var invoke = new InvokeApi().input(scorerArgs).version(version); diff --git a/braintrust-sdk/src/main/java/dev/braintrust/json/BraintrustJsonMapper.java b/braintrust-sdk/src/main/java/dev/braintrust/json/BraintrustJsonMapper.java index 91952df2..3e872463 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/json/BraintrustJsonMapper.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/json/BraintrustJsonMapper.java @@ -8,7 +8,9 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.function.Consumer; +import java.util.function.Function; import lombok.SneakyThrows; /** Centralized ObjectMapper for the Braintrust SDK. */ @@ -69,6 +71,16 @@ public static T fromJson(String jsonString, Class targetClass) { return get().readValue(jsonString, targetClass); } + /** + * Returns a converter function that deserializes raw JSON-decoded values (e.g. {@code + * Map}, {@code List}, {@code String}, {@code Number}, {@code Boolean}) into the + * given type using the shared mapper. Null values pass through as null. + */ + public static Function converter(Class targetClass) { + Objects.requireNonNull(targetClass); + return raw -> raw == null ? null : get().convertValue(raw, targetClass); + } + static synchronized void reset() { instance = null; configurers.clear(); diff --git a/braintrust-sdk/src/test/java/dev/braintrust/devserver/DevserverTest.java b/braintrust-sdk/src/test/java/dev/braintrust/devserver/DevserverTest.java index 04b2e2d4..0316b10e 100644 --- a/braintrust-sdk/src/test/java/dev/braintrust/devserver/DevserverTest.java +++ b/braintrust-sdk/src/test/java/dev/braintrust/devserver/DevserverTest.java @@ -39,6 +39,12 @@ class DevserverTest { private static final String REMOTE_EVAL_NAME = "food-type-classifier"; private static final String PARAM_EVAL_NAME = "param-eval"; + private static final String TYPED_EVAL_NAME = "typed-eval"; + + public record TypedInput(String prompt, int n) {} + + public record TypedOutput(String answer) {} + private static final String TASK_ERROR_EVAL_NAME = "task-error-eval"; private static final String SCORER_ERROR_EVAL_NAME = "scorer-error-eval"; private static final BraintrustUtils.Parent PLAYGROUND_PARENT = @@ -148,8 +154,23 @@ public dev.braintrust.eval.TaskResult apply( .scorer(Scorer.of("static_scorer", (expected, result) -> 1.0)) .build(); + // Typed eval: input/expected are deserialized into records via builder(Class, Class) + RemoteEval typedEval = + RemoteEval.builder(TypedInput.class, TypedOutput.class) + .name(TYPED_EVAL_NAME) + .taskFunction(input -> new TypedOutput(input.prompt() + ":" + input.n())) + .scorer( + Scorer.of( + "typed_scorer", + (expected, result) -> + expected.answer().equals(result.answer()) + ? 1.0 + : 0.0)) + .build(); + server = Devserver.builder() + .registerEval(typedEval) .config(testHarness.braintrust().config()) .registerEval(testEval) .registerEval(paramEval) @@ -203,6 +224,91 @@ void testHealthCheck() throws Exception { assertEquals("text/plain", response.headers().firstValue("Content-Type").orElse("")); } + @Test + void testStreamingEvalWithTypedInputOutput() throws Exception { + // Inline case data arrives as raw JSON; the typed eval (built via + // RemoteEval.builder(Class, Class)) must deserialize it into records before + // invoking the task and scorers. + EvalRequest evalRequest = new EvalRequest(); + evalRequest.setName(TYPED_EVAL_NAME); + evalRequest.setStream(true); + + EvalRequest.DataSpec dataSpec = new EvalRequest.DataSpec(); + EvalRequest.EvalCaseData case1 = new EvalRequest.EvalCaseData(); + case1.setInput(Map.of("prompt", "apple", "n", 2)); + case1.setExpected(Map.of("answer", "apple:2")); + dataSpec.setData(List.of(case1)); + evalRequest.setData(dataSpec); + + Map parentSpec = + Map.of( + "object_type", PLAYGROUND_PARENT.type(), + "object_id", PLAYGROUND_PARENT.id(), + "propagated_event", + Map.of("span_attributes", Map.of("generation", "test-gen-typed"))); + evalRequest.setParent(parentSpec); + + String requestBody = JSON_MAPPER.writeValueAsString(evalRequest); + + HttpURLConnection conn = + (HttpURLConnection) new URI(TEST_URL + "/eval").toURL().openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("x-bt-auth-token", testHarness.braintrustApiKey()); + conn.setRequestProperty("x-bt-project-id", TestHarness.defaultProjectId()); + conn.setRequestProperty("x-bt-org-name", TestHarness.defaultOrgName()); + conn.setDoOutput(true); + conn.getOutputStream().write(requestBody.getBytes(StandardCharsets.UTF_8)); + conn.getOutputStream().flush(); + + assertEquals(200, conn.getResponseCode()); + + List> events = readSseEvents(conn); + + List> progressEvents = + events.stream().filter(e -> "progress".equals(e.get("event"))).toList(); + List> summaryEvents = + events.stream().filter(e -> "summary".equals(e.get("event"))).toList(); + assertEquals(1, progressEvents.size(), "Should have 1 progress event"); + assertEquals(1, summaryEvents.size(), "Should have 1 summary event"); + + // The task only compiles/runs against typed records: output proves input was + // deserialized into TypedInput rather than passed through as a LinkedHashMap. + JsonNode progressData = JSON_MAPPER.readTree(progressEvents.get(0).get("data")); + JsonNode taskResult = JSON_MAPPER.readTree(progressData.get("data").asText()); + assertEquals("apple:2", taskResult.get("answer").asText()); + + // The scorer compares typed expected vs typed result: 1.0 proves expected was + // deserialized into TypedOutput. + JsonNode summaryData = JSON_MAPPER.readTree(summaryEvents.get(0).get("data")); + JsonNode typedScorer = summaryData.get("scores").get("typed_scorer"); + assertEquals(1.0, typedScorer.get("score").asDouble(), 0.001); + } + + private static List> readSseEvents(HttpURLConnection conn) + throws Exception { + try (BufferedReader reader = + new BufferedReader( + new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + List> events = new ArrayList<>(); + String line; + String currentEvent = null; + StringBuilder currentData = new StringBuilder(); + while ((line = reader.readLine()) != null) { + if (line.startsWith("event: ")) { + currentEvent = line.substring(7); + } else if (line.startsWith("data: ")) { + currentData.append(line.substring(6)); + } else if (line.isEmpty() && currentEvent != null) { + events.add(Map.of("event", currentEvent, "data", currentData.toString())); + currentEvent = null; + currentData = new StringBuilder(); + } + } + return events; + } + } + @Test void testStreamingEval() throws Exception { // Create eval request with inline data using EvalRequest types diff --git a/braintrust-sdk/src/test/java/dev/braintrust/devserver/RemoteEvalTest.java b/braintrust-sdk/src/test/java/dev/braintrust/devserver/RemoteEvalTest.java new file mode 100644 index 00000000..787d8bc4 --- /dev/null +++ b/braintrust-sdk/src/test/java/dev/braintrust/devserver/RemoteEvalTest.java @@ -0,0 +1,69 @@ +package dev.braintrust.devserver; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class RemoteEvalTest { + + public record MyInput(String prompt, int temperature) {} + + public record MyOutput(String answer) {} + + @Test + void builderWithClassesConvertsRawJsonValues() { + RemoteEval eval = + RemoteEval.builder(MyInput.class, MyOutput.class) + .name("typed-eval") + .taskFunction(input -> new MyOutput(input.prompt())) + .build(); + + Map rawInput = new LinkedHashMap<>(); + rawInput.put("prompt", "hello"); + rawInput.put("temperature", 7); + + MyInput input = eval.getInputConverter().apply(rawInput); + assertEquals(new MyInput("hello", 7), input); + + MyOutput expected = eval.getOutputConverter().apply(Map.of("answer", "world")); + assertEquals(new MyOutput("world"), expected); + + // null values pass through as null + assertNull(eval.getInputConverter().apply(null)); + assertNull(eval.getOutputConverter().apply(null)); + } + + @Test + void builderWithConvertersUsesSuppliedFunctions() { + RemoteEval eval = + RemoteEval.builder( + raw -> { + @SuppressWarnings("unchecked") + var map = (Map) raw; + return new MyInput( + (String) map.get("prompt"), + ((Number) map.get("temperature")).intValue()); + }, + raw -> String.valueOf(raw)) + .name("converter-eval") + .taskFunction(input -> input.prompt()) + .build(); + + MyInput input = eval.getInputConverter().apply(Map.of("prompt", "hi", "temperature", 3)); + assertEquals(new MyInput("hi", 3), input); + assertEquals("42", eval.getOutputConverter().apply(42)); + } + + @Test + @SuppressWarnings("deprecation") + void deprecatedBuilderPassesRawValuesThrough() { + RemoteEval eval = + RemoteEval.builder().name("legacy-eval").taskFunction(input -> input).build(); + + Map raw = Map.of("prompt", "hello"); + assertSame(raw, eval.getInputConverter().apply(raw)); + assertSame(raw, eval.getOutputConverter().apply(raw)); + } +} diff --git a/braintrust-sdk/src/test/java/dev/braintrust/eval/DatasetBrainstoreImplTest.java b/braintrust-sdk/src/test/java/dev/braintrust/eval/DatasetBrainstoreImplTest.java index 17d712a1..e8b91fb4 100644 --- a/braintrust-sdk/src/test/java/dev/braintrust/eval/DatasetBrainstoreImplTest.java +++ b/braintrust-sdk/src/test/java/dev/braintrust/eval/DatasetBrainstoreImplTest.java @@ -306,6 +306,169 @@ void testTagsPopulatedFromDatasetRow() { assertEquals("unit-test", tags.get(0)); } + public record MyInput(String prompt, int temperature) {} + + public record MyOutput(String answer) {} + + private void stubTypedRow() { + wireMock.stubFor( + post(urlEqualTo("/v1/dataset/" + datasetId + "/fetch")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "events": [ + { + "object_type": "dataset", + "dataset_id": "%s", + "id": "typed-row-1", + "_xact_id": "1", + "created": "2024-01-01T00:00:00Z", + "input": {"prompt": "hello", "temperature": 7}, + "expected": {"answer": "world"} + } + ], + "cursor": null + } + """ + .formatted(datasetId)))); + } + + @Test + void testTypedDeserializationWithClasses() { + stubTypedRow(); + + Dataset dataset = + new DatasetBrainstoreImpl<>( + apiClient, datasetId, "test-version", MyInput.class, MyOutput.class); + + List> cases = new ArrayList<>(); + dataset.forEach(cases::add); + + assertEquals(1, cases.size()); + assertEquals(MyInput.class, cases.get(0).input().getClass()); + assertEquals(new MyInput("hello", 7), cases.get(0).input()); + assertEquals(new MyOutput("world"), cases.get(0).expected()); + } + + @Test + void testTypedDeserializationWithConverters() { + stubTypedRow(); + + Dataset dataset = + new DatasetBrainstoreImpl<>( + apiClient, + datasetId, + "test-version", + raw -> { + @SuppressWarnings("unchecked") + var map = (Map) raw; + return new MyInput( + (String) map.get("prompt"), + ((Number) map.get("temperature")).intValue()); + }, + raw -> { + @SuppressWarnings("unchecked") + var map = (Map) raw; + return (String) map.get("answer"); + }); + + List> cases = new ArrayList<>(); + dataset.forEach(cases::add); + + assertEquals(1, cases.size()); + assertEquals(new MyInput("hello", 7), cases.get(0).input()); + assertEquals("world", cases.get(0).expected()); + } + + @Test + void testTypedDeserializationNullValues() { + wireMock.stubFor( + post(urlEqualTo("/v1/dataset/" + datasetId + "/fetch")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "events": [ + { + "object_type": "dataset", + "dataset_id": "%s", + "id": "null-row-1", + "_xact_id": "1", + "created": "2024-01-01T00:00:00Z", + "input": {"prompt": "hello", "temperature": 7} + } + ], + "cursor": null + } + """ + .formatted(datasetId)))); + + Dataset dataset = + new DatasetBrainstoreImpl<>( + apiClient, datasetId, "test-version", MyInput.class, MyOutput.class); + + List> cases = new ArrayList<>(); + dataset.forEach(cases::add); + + assertEquals(1, cases.size()); + assertEquals(new MyInput("hello", 7), cases.get(0).input()); + assertNull(cases.get(0).expected()); + } + + @Test + void testTypedFetchFromBraintrust() { + String projectName = "test-project"; + String datasetName = "typed-dataset"; + + wireMock.stubFor( + get(urlPathEqualTo("/v1/dataset")) + .withQueryParam("project_name", equalTo(projectName)) + .withQueryParam("dataset_name", equalTo(datasetName)) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "objects": [ + { + "id": "%s", + "project_id": "00000000-0000-0000-0000-000000000456", + "name": "typed-dataset", + "_xact_id": "1", + "created": "2024-01-01T00:00:00Z" + } + ] + } + """ + .formatted(datasetId)))); + stubTypedRow(); + + Dataset dataset = + Dataset.fetchFromBraintrust( + apiClient, projectName, datasetName, "1", MyInput.class, MyOutput.class); + + // typed fetch must still return the Brainstore-backed impl so Eval.run() links + // experiment <-> dataset + assertInstanceOf(DatasetBrainstoreImpl.class, dataset); + assertEquals(datasetId, dataset.id()); + + List> cases = new ArrayList<>(); + dataset.forEach(cases::add); + + assertEquals(1, cases.size()); + assertEquals(new MyInput("hello", 7), cases.get(0).input()); + assertEquals(new MyOutput("world"), cases.get(0).expected()); + } + @Test void testFetchFromBraintrustNotFound() { String projectName = "test-project"; diff --git a/braintrust-sdk/src/test/java/dev/braintrust/eval/ScorerBrainstoreImplSerializationTest.java b/braintrust-sdk/src/test/java/dev/braintrust/eval/ScorerBrainstoreImplSerializationTest.java new file mode 100644 index 00000000..6695448f --- /dev/null +++ b/braintrust-sdk/src/test/java/dev/braintrust/eval/ScorerBrainstoreImplSerializationTest.java @@ -0,0 +1,152 @@ +package dev.braintrust.eval; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import dev.braintrust.api.BraintrustOpenApiClient; +import dev.braintrust.config.BraintrustConfig; +import dev.braintrust.json.BraintrustJsonMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Verifies that user-supplied task/dataset objects in scorer invoke payloads are serialized with + * {@link BraintrustJsonMapper} (snake_case, Jackson annotations, user modules) rather than the + * OpenAPI client's internal mapper. + */ +public class ScorerBrainstoreImplSerializationTest { + + @RegisterExtension + static WireMockExtension wireMock = + WireMockExtension.newInstance().options(wireMockConfig().dynamicPort()).build(); + + private static final String FUNCTION_ID = "00000000-0000-0000-0000-000000000abc"; + private static final String PROJECT_ID = "00000000-0000-0000-0000-000000000def"; + + private BraintrustOpenApiClient apiClient; + + public record MyInput(String userPrompt, @JsonIgnore String secretKey) {} + + public record MyOutput(String finalAnswer) {} + + @BeforeEach + void beforeEach() { + wireMock.resetAll(); + var config = + BraintrustConfig.builder() + .apiKey("test-api-key") + .apiUrl("http://localhost:" + wireMock.getPort()) + .build(); + apiClient = BraintrustOpenApiClient.of(config); + + wireMock.stubFor( + get(urlPathEqualTo("/v1/function/" + FUNCTION_ID)) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "id": "%s", + "project_id": "%s", + "name": "my-scorer", + "slug": "my-scorer", + "_xact_id": "1", + "created": "2024-01-01T00:00:00Z", + "log_id": "p", + "org_id": "00000000-0000-0000-0000-000000000001", + "function_data": {"type": "code"} + } + """ + .formatted(FUNCTION_ID, PROJECT_ID)))); + + wireMock.stubFor( + get(urlPathEqualTo("/v1/project/" + PROJECT_ID)) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "id": "%s", + "org_id": "00000000-0000-0000-0000-000000000001", + "name": "test-project" + } + """ + .formatted(PROJECT_ID)))); + + wireMock.stubFor( + post(urlPathEqualTo("/v1/function/" + FUNCTION_ID + "/invoke")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("0.5"))); + } + + @Test + void scorerArgsAreSerializedWithBraintrustJsonMapper() { + Scorer scorer = new ScorerBrainstoreImpl<>(apiClient, FUNCTION_ID, null); + + var datasetCase = + DatasetCase.of(new MyInput("what is 2+2?", "hunter2"), new MyOutput("four")); + var taskResult = new TaskResult<>(new MyOutput("4"), datasetCase); + + var scores = scorer.score(taskResult); + assertEquals(1, scores.size()); + assertEquals(0.5, scores.get(0).value(), 0.001); + + wireMock.verify( + postRequestedFor(urlPathEqualTo("/v1/function/" + FUNCTION_ID + "/invoke")) + // snake_case naming from BraintrustJsonMapper + .withRequestBody( + matchingJsonPath( + "$.input.input.user_prompt", equalTo("what is 2+2?"))) + .withRequestBody( + matchingJsonPath("$.input.output.final_answer", equalTo("4"))) + .withRequestBody( + matchingJsonPath("$.input.expected.final_answer", equalTo("four"))) + // @JsonIgnore fields must not leak into the payload + .withRequestBody(notMatching(".*secret.*"))); + } + + @Test + void customArgumentConverterControlsSerialization() { + // A converter that wraps every argument value, proving user control over serialization. + Scorer scorer = + new ScorerBrainstoreImpl<>( + apiClient, + FUNCTION_ID, + null, + value -> + value == null + ? null + : java.util.Map.of( + "wrapped", + BraintrustJsonMapper.get().valueToTree(value))); + + var datasetCase = + DatasetCase.of(new MyInput("what is 2+2?", "hunter2"), new MyOutput("four")); + var taskResult = new TaskResult<>(new MyOutput("4"), datasetCase); + + var scores = scorer.score(taskResult); + assertEquals(1, scores.size()); + assertEquals(0.5, scores.get(0).value(), 0.001); + + wireMock.verify( + postRequestedFor(urlPathEqualTo("/v1/function/" + FUNCTION_ID + "/invoke")) + .withRequestBody( + matchingJsonPath( + "$.input.input.wrapped.user_prompt", + equalTo("what is 2+2?"))) + .withRequestBody( + matchingJsonPath( + "$.input.output.wrapped.final_answer", equalTo("4")))); + } +} diff --git a/examples/remote-eval/src/main/java/dev/braintrust/examples/RemoteEvalExample.java b/examples/remote-eval/src/main/java/dev/braintrust/examples/RemoteEvalExample.java index ad83ae3f..36f57135 100644 --- a/examples/remote-eval/src/main/java/dev/braintrust/examples/RemoteEvalExample.java +++ b/examples/remote-eval/src/main/java/dev/braintrust/examples/RemoteEvalExample.java @@ -18,7 +18,7 @@ public static void main(String[] args) throws Exception { var openAIClient = BraintrustOpenAI.wrapOpenAI(openTelemetry, OpenAIOkHttpClient.fromEnv()); RemoteEval foodTypeEval = - RemoteEval.builder() + RemoteEval.builder(String.class, String.class) .name("food-type-classifier") .taskFunction( food -> {