diff --git a/agenteval-datasets/pom.xml b/agenteval-datasets/pom.xml new file mode 100644 index 0000000..e867ef1 --- /dev/null +++ b/agenteval-datasets/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + + com.agenteval + agenteval-parent + 0.1.0-SNAPSHOT + + + agenteval-datasets + AgentEval Datasets + Dataset loading, management, and serialization + + + + com.agenteval + agenteval-core + + + org.slf4j + slf4j-api + + + diff --git a/agenteval-datasets/src/main/java/com/agenteval/datasets/DatasetException.java b/agenteval-datasets/src/main/java/com/agenteval/datasets/DatasetException.java new file mode 100644 index 0000000..db02093 --- /dev/null +++ b/agenteval-datasets/src/main/java/com/agenteval/datasets/DatasetException.java @@ -0,0 +1,17 @@ +package com.agenteval.datasets; + +/** + * Unchecked exception for dataset loading/writing errors. + */ +public class DatasetException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public DatasetException(String message) { + super(message); + } + + public DatasetException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/agenteval-datasets/src/main/java/com/agenteval/datasets/DatasetLoader.java b/agenteval-datasets/src/main/java/com/agenteval/datasets/DatasetLoader.java new file mode 100644 index 0000000..67707d3 --- /dev/null +++ b/agenteval-datasets/src/main/java/com/agenteval/datasets/DatasetLoader.java @@ -0,0 +1,28 @@ +package com.agenteval.datasets; + +import java.io.InputStream; +import java.nio.file.Path; + +/** + * Interface for loading evaluation datasets from various sources. + */ +public interface DatasetLoader { + + /** + * Loads a dataset from a file path. + * + * @param path the path to the dataset file + * @return the loaded dataset + * @throws DatasetException if loading fails + */ + EvalDataset load(Path path); + + /** + * Loads a dataset from an input stream. + * + * @param inputStream the input stream to read from + * @return the loaded dataset + * @throws DatasetException if loading fails + */ + EvalDataset load(InputStream inputStream); +} diff --git a/agenteval-datasets/src/main/java/com/agenteval/datasets/DatasetWriter.java b/agenteval-datasets/src/main/java/com/agenteval/datasets/DatasetWriter.java new file mode 100644 index 0000000..64f4c78 --- /dev/null +++ b/agenteval-datasets/src/main/java/com/agenteval/datasets/DatasetWriter.java @@ -0,0 +1,28 @@ +package com.agenteval.datasets; + +import java.io.OutputStream; +import java.nio.file.Path; + +/** + * Interface for writing evaluation datasets to various targets. + */ +public interface DatasetWriter { + + /** + * Writes a dataset to a file path. + * + * @param dataset the dataset to write + * @param path the target file path + * @throws DatasetException if writing fails + */ + void write(EvalDataset dataset, Path path); + + /** + * Writes a dataset to an output stream. + * + * @param dataset the dataset to write + * @param outputStream the target output stream + * @throws DatasetException if writing fails + */ + void write(EvalDataset dataset, OutputStream outputStream); +} diff --git a/agenteval-datasets/src/main/java/com/agenteval/datasets/EvalDataset.java b/agenteval-datasets/src/main/java/com/agenteval/datasets/EvalDataset.java new file mode 100644 index 0000000..0f4cbbb --- /dev/null +++ b/agenteval-datasets/src/main/java/com/agenteval/datasets/EvalDataset.java @@ -0,0 +1,91 @@ +package com.agenteval.datasets; + +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.datasets.json.JsonDatasetWriter; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * An immutable collection of evaluation test cases with metadata. + */ +@JsonDeserialize(builder = EvalDataset.Builder.class) +public final class EvalDataset { + + private final String name; + private final String version; + private final List testCases; + private final Map metadata; + + private EvalDataset(Builder builder) { + this.name = builder.name; + this.version = builder.version; + this.testCases = builder.testCases == null ? List.of() : List.copyOf(builder.testCases); + this.metadata = builder.metadata == null ? Map.of() : Map.copyOf(builder.metadata); + } + + public static Builder builder() { + return new Builder(); + } + + public String getName() { return name; } + public String getVersion() { return version; } + public List getTestCases() { return testCases; } + public Map getMetadata() { return metadata; } + + /** + * Returns the number of test cases in this dataset. + */ + public int size() { + return testCases.size(); + } + + /** + * Saves this dataset to a JSON file. + * + * @param path the target file path + * @throws DatasetException if writing fails + */ + public void save(Path path) { + new JsonDatasetWriter().write(this, path); + } + + @JsonPOJOBuilder(withPrefix = "") + public static final class Builder { + private String name; + private String version; + private List testCases; + private Map metadata; + + private Builder() {} + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder version(String version) { + this.version = version; + return this; + } + + public Builder testCases(List testCases) { + this.testCases = testCases; + return this; + } + + public Builder metadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public EvalDataset build() { + Objects.requireNonNull(testCases, "testCases must not be null"); + return new EvalDataset(this); + } + } +} diff --git a/agenteval-datasets/src/main/java/com/agenteval/datasets/json/JsonDatasetLoader.java b/agenteval-datasets/src/main/java/com/agenteval/datasets/json/JsonDatasetLoader.java new file mode 100644 index 0000000..52743b0 --- /dev/null +++ b/agenteval-datasets/src/main/java/com/agenteval/datasets/json/JsonDatasetLoader.java @@ -0,0 +1,87 @@ +package com.agenteval.datasets.json; + +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.datasets.DatasetException; +import com.agenteval.datasets.DatasetLoader; +import com.agenteval.datasets.EvalDataset; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** + * Loads evaluation datasets from JSON files. + * + *

Supports two formats:

+ *
    + *
  • Envelope format: {@code {"name":"...","testCases":[...]}}
  • + *
  • Bare array format: {@code [{...}, ...]}
  • + *
+ * + *

Auto-detects the format by inspecting the first non-whitespace character.

+ */ +public final class JsonDatasetLoader implements DatasetLoader { + + private static final Logger LOG = LoggerFactory.getLogger(JsonDatasetLoader.class); + private static final TypeReference> TEST_CASE_LIST = + new TypeReference<>() { }; + + private final ObjectMapper mapper; + + public JsonDatasetLoader() { + this(new ObjectMapper()); + } + + public JsonDatasetLoader(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public EvalDataset load(Path path) { + LOG.debug("Loading dataset from {}", path); + try { + byte[] bytes = Files.readAllBytes(path); + return parse(bytes); + } catch (IOException e) { + throw new DatasetException("Failed to load dataset from " + path, e); + } + } + + @Override + public EvalDataset load(InputStream inputStream) { + LOG.debug("Loading dataset from input stream"); + try { + byte[] bytes = inputStream.readAllBytes(); + return parse(bytes); + } catch (IOException e) { + throw new DatasetException("Failed to load dataset from input stream", e); + } + } + + private EvalDataset parse(byte[] bytes) throws IOException { + if (isBareArray(bytes)) { + LOG.debug("Detected bare array format"); + List testCases = mapper.readValue(bytes, TEST_CASE_LIST); + return EvalDataset.builder() + .testCases(testCases) + .build(); + } + LOG.debug("Detected envelope format"); + return mapper.readValue(bytes, EvalDataset.class); + } + + private static boolean isBareArray(byte[] bytes) { + for (byte b : bytes) { + if (!Character.isWhitespace(b)) { + return b == '['; + } + } + return false; + } +} diff --git a/agenteval-datasets/src/main/java/com/agenteval/datasets/json/JsonDatasetWriter.java b/agenteval-datasets/src/main/java/com/agenteval/datasets/json/JsonDatasetWriter.java new file mode 100644 index 0000000..59697be --- /dev/null +++ b/agenteval-datasets/src/main/java/com/agenteval/datasets/json/JsonDatasetWriter.java @@ -0,0 +1,51 @@ +package com.agenteval.datasets.json; + +import com.agenteval.datasets.DatasetException; +import com.agenteval.datasets.DatasetWriter; +import com.agenteval.datasets.EvalDataset; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Writes evaluation datasets to JSON files in envelope format with pretty-printing. + */ +public final class JsonDatasetWriter implements DatasetWriter { + + private static final Logger LOG = LoggerFactory.getLogger(JsonDatasetWriter.class); + + private final ObjectMapper mapper; + + public JsonDatasetWriter() { + this(new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT)); + } + + public JsonDatasetWriter(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public void write(EvalDataset dataset, Path path) { + LOG.debug("Writing dataset '{}' to {}", dataset.getName(), path); + try (OutputStream out = Files.newOutputStream(path)) { + write(dataset, out); + } catch (IOException e) { + throw new DatasetException("Failed to write dataset to " + path, e); + } + } + + @Override + public void write(EvalDataset dataset, OutputStream outputStream) { + try { + mapper.writeValue(outputStream, dataset); + } catch (IOException e) { + throw new DatasetException("Failed to write dataset to output stream", e); + } + } +} diff --git a/agenteval-datasets/src/test/java/com/agenteval/datasets/EvalDatasetTest.java b/agenteval-datasets/src/test/java/com/agenteval/datasets/EvalDatasetTest.java new file mode 100644 index 0000000..75fc23c --- /dev/null +++ b/agenteval-datasets/src/test/java/com/agenteval/datasets/EvalDatasetTest.java @@ -0,0 +1,72 @@ +package com.agenteval.datasets; + +import com.agenteval.core.model.AgentTestCase; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EvalDatasetTest { + + @Test + void shouldBuildDatasetWithAllFields() { + var tc = AgentTestCase.builder().input("test input").build(); + var dataset = EvalDataset.builder() + .name("my-dataset") + .version("2.0") + .testCases(List.of(tc)) + .metadata(Map.of("key", "value")) + .build(); + + assertThat(dataset.getName()).isEqualTo("my-dataset"); + assertThat(dataset.getVersion()).isEqualTo("2.0"); + assertThat(dataset.getTestCases()).hasSize(1); + assertThat(dataset.getMetadata()).containsEntry("key", "value"); + assertThat(dataset.size()).isEqualTo(1); + } + + @Test + void shouldBuildDatasetWithMinimalFields() { + var dataset = EvalDataset.builder() + .testCases(List.of(AgentTestCase.builder().input("q").build())) + .build(); + + assertThat(dataset.getName()).isNull(); + assertThat(dataset.getVersion()).isNull(); + assertThat(dataset.getTestCases()).hasSize(1); + assertThat(dataset.getMetadata()).isEmpty(); + } + + @Test + void shouldRejectNullTestCases() { + assertThatThrownBy(() -> EvalDataset.builder().build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("testCases"); + } + + @Test + void shouldDefensiveCopyTestCases() { + var list = new java.util.ArrayList<>( + List.of(AgentTestCase.builder().input("q").build())); + var dataset = EvalDataset.builder().testCases(list).build(); + list.add(AgentTestCase.builder().input("extra").build()); + assertThat(dataset.getTestCases()).hasSize(1); + } + + @Test + void shouldSaveToFile(@TempDir Path tempDir) { + var dataset = EvalDataset.builder() + .name("save-test") + .testCases(List.of(AgentTestCase.builder().input("q").build())) + .build(); + + Path outFile = tempDir.resolve("out.json"); + dataset.save(outFile); + assertThat(outFile).exists().isNotEmptyFile(); + } +} diff --git a/agenteval-datasets/src/test/java/com/agenteval/datasets/json/JsonDatasetLoaderTest.java b/agenteval-datasets/src/test/java/com/agenteval/datasets/json/JsonDatasetLoaderTest.java new file mode 100644 index 0000000..6dcf6e9 --- /dev/null +++ b/agenteval-datasets/src/test/java/com/agenteval/datasets/json/JsonDatasetLoaderTest.java @@ -0,0 +1,96 @@ +package com.agenteval.datasets.json; + +import com.agenteval.datasets.DatasetException; +import com.agenteval.datasets.EvalDataset; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JsonDatasetLoaderTest { + + private final JsonDatasetLoader loader = new JsonDatasetLoader(); + + @Test + void shouldLoadEnvelopeFormat() { + Path path = Path.of("src/test/resources/test-dataset.json"); + EvalDataset dataset = loader.load(path); + + assertThat(dataset.getName()).isEqualTo("golden-set"); + assertThat(dataset.getVersion()).isEqualTo("1.0"); + assertThat(dataset.getTestCases()).hasSize(2); + assertThat(dataset.getTestCases().get(0).getInput()).isEqualTo("How do I get a refund?"); + assertThat(dataset.getMetadata()).containsEntry("source", "manual"); + } + + @Test + void shouldLoadBareArrayFormat() { + Path path = Path.of("src/test/resources/test-dataset-bare.json"); + EvalDataset dataset = loader.load(path); + + assertThat(dataset.getName()).isNull(); + assertThat(dataset.getTestCases()).hasSize(2); + assertThat(dataset.getTestCases().get(1).getInput()).isEqualTo("What are your business hours?"); + } + + @Test + void shouldLoadEnvelopeFromInputStream() { + String json = """ + { + "name": "stream-set", + "testCases": [ + {"input": "hello", "expectedOutput": "world"} + ] + } + """; + InputStream is = new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)); + EvalDataset dataset = loader.load(is); + + assertThat(dataset.getName()).isEqualTo("stream-set"); + assertThat(dataset.getTestCases()).hasSize(1); + } + + @Test + void shouldLoadBareArrayFromInputStream() { + String json = """ + [ + {"input": "q1", "expectedOutput": "a1"}, + {"input": "q2", "expectedOutput": "a2"} + ] + """; + InputStream is = new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)); + EvalDataset dataset = loader.load(is); + + assertThat(dataset.getName()).isNull(); + assertThat(dataset.getTestCases()).hasSize(2); + } + + @Test + void shouldAutoDetectWithLeadingWhitespace() { + String json = " \n [{\"input\": \"q\"}]"; + InputStream is = new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)); + EvalDataset dataset = loader.load(is); + + assertThat(dataset.getTestCases()).hasSize(1); + } + + @Test + void shouldThrowOnInvalidJson() { + String json = "not json"; + InputStream is = new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)); + + assertThatThrownBy(() -> loader.load(is)) + .isInstanceOf(DatasetException.class); + } + + @Test + void shouldThrowOnNonExistentFile() { + assertThatThrownBy(() -> loader.load(Path.of("nonexistent.json"))) + .isInstanceOf(DatasetException.class); + } +} diff --git a/agenteval-datasets/src/test/java/com/agenteval/datasets/json/JsonDatasetWriterTest.java b/agenteval-datasets/src/test/java/com/agenteval/datasets/json/JsonDatasetWriterTest.java new file mode 100644 index 0000000..fed3945 --- /dev/null +++ b/agenteval-datasets/src/test/java/com/agenteval/datasets/json/JsonDatasetWriterTest.java @@ -0,0 +1,81 @@ +package com.agenteval.datasets.json; + +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.datasets.EvalDataset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class JsonDatasetWriterTest { + + private final JsonDatasetWriter writer = new JsonDatasetWriter(); + private final JsonDatasetLoader loader = new JsonDatasetLoader(); + + @Test + void shouldWriteToOutputStream() { + var dataset = EvalDataset.builder() + .name("test-write") + .version("1.0") + .testCases(List.of( + AgentTestCase.builder().input("q1").expectedOutput("a1").build() + )) + .metadata(Map.of("key", "value")) + .build(); + + var out = new ByteArrayOutputStream(); + writer.write(dataset, out); + + String json = out.toString(); + assertThat(json).contains("\"name\" : \"test-write\""); + assertThat(json).contains("\"input\" : \"q1\""); + } + + @Test + void shouldRoundTripThroughFile(@TempDir Path tempDir) { + var original = EvalDataset.builder() + .name("round-trip") + .version("2.0") + .testCases(List.of( + AgentTestCase.builder() + .input("How do I reset my password?") + .expectedOutput("Go to Settings > Security.") + .build(), + AgentTestCase.builder() + .input("Where is my order?") + .expectedOutput("Check your order status page.") + .build() + )) + .build(); + + Path file = tempDir.resolve("dataset.json"); + writer.write(original, file); + EvalDataset loaded = loader.load(file); + + assertThat(loaded.getName()).isEqualTo("round-trip"); + assertThat(loaded.getVersion()).isEqualTo("2.0"); + assertThat(loaded.getTestCases()).hasSize(2); + assertThat(loaded.getTestCases().get(0).getInput()) + .isEqualTo("How do I reset my password?"); + } + + @Test + void shouldWritePrettyPrinted() { + var dataset = EvalDataset.builder() + .name("pretty") + .testCases(List.of(AgentTestCase.builder().input("q").build())) + .build(); + + var out = new ByteArrayOutputStream(); + writer.write(dataset, out); + + String json = out.toString(); + assertThat(json).contains("\n"); + assertThat(json.lines().count()).isGreaterThan(1); + } +} diff --git a/agenteval-datasets/src/test/resources/test-dataset-bare.json b/agenteval-datasets/src/test/resources/test-dataset-bare.json new file mode 100644 index 0000000..6b7c5d9 --- /dev/null +++ b/agenteval-datasets/src/test/resources/test-dataset-bare.json @@ -0,0 +1,10 @@ +[ + { + "input": "How do I get a refund?", + "expectedOutput": "You can request a refund within 30 days of purchase." + }, + { + "input": "What are your business hours?", + "expectedOutput": "We are open Monday through Friday, 9am to 5pm." + } +] diff --git a/agenteval-datasets/src/test/resources/test-dataset.json b/agenteval-datasets/src/test/resources/test-dataset.json new file mode 100644 index 0000000..6958c5b --- /dev/null +++ b/agenteval-datasets/src/test/resources/test-dataset.json @@ -0,0 +1,20 @@ +{ + "name": "golden-set", + "version": "1.0", + "testCases": [ + { + "input": "How do I get a refund?", + "expectedOutput": "You can request a refund within 30 days of purchase.", + "metadata": {"category": "refund"} + }, + { + "input": "What are your business hours?", + "expectedOutput": "We are open Monday through Friday, 9am to 5pm.", + "metadata": {"category": "general"} + } + ], + "metadata": { + "source": "manual", + "domain": "customer-support" + } +} diff --git a/agenteval-junit5/pom.xml b/agenteval-junit5/pom.xml new file mode 100644 index 0000000..15845fe --- /dev/null +++ b/agenteval-junit5/pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + + + com.agenteval + agenteval-parent + 0.1.0-SNAPSHOT + + + agenteval-junit5 + AgentEval JUnit 5 + JUnit 5 extension, annotations, and assertion API for agent evaluation + + + + com.agenteval + agenteval-core + + + com.agenteval + agenteval-datasets + + + org.junit.jupiter + junit-jupiter-api + + + org.junit.jupiter + junit-jupiter-params + + + org.slf4j + slf4j-api + + + org.mockito + mockito-core + test + + + org.junit.platform + junit-platform-testkit + test + + + diff --git a/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/AgentTest.java b/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/AgentTest.java new file mode 100644 index 0000000..49ec911 --- /dev/null +++ b/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/AgentTest.java @@ -0,0 +1,31 @@ +package com.agenteval.junit5.annotation; + +import com.agenteval.junit5.extension.AgentEvalExtension; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Meta-annotation combining {@code @Test}, {@code @Tag("eval")}, + * and {@code @ExtendWith(AgentEvalExtension.class)}. + * + *
{@code
+ * @AgentTest
+ * @Metric(value = AnswerRelevancyMetric.class, threshold = 0.7)
+ * void testRelevancy(AgentTestCase testCase) {
+ *     testCase.setActualOutput(agent.ask(testCase.getInput()));
+ * }
+ * }
+ */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Test +@Tag("eval") +@ExtendWith(AgentEvalExtension.class) +public @interface AgentTest { +} diff --git a/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/DatasetSource.java b/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/DatasetSource.java new file mode 100644 index 0000000..40a6b5b --- /dev/null +++ b/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/DatasetSource.java @@ -0,0 +1,30 @@ +package com.agenteval.junit5.annotation; + +import com.agenteval.junit5.extension.DatasetArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Provides {@link com.agenteval.core.model.AgentTestCase} arguments from a JSON dataset file. + * + *
{@code
+ * @ParameterizedTest
+ * @DatasetSource("datasets/golden-set.json")
+ * @Metric(value = AnswerRelevancyMetric.class, threshold = 0.7)
+ * void testWithDataset(AgentTestCase testCase) { ... }
+ * }
+ */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@ArgumentsSource(DatasetArgumentsProvider.class) +public @interface DatasetSource { + + /** + * Classpath resource path to the JSON dataset file. + */ + String value(); +} diff --git a/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/Metric.java b/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/Metric.java new file mode 100644 index 0000000..a8b3aff --- /dev/null +++ b/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/Metric.java @@ -0,0 +1,42 @@ +package com.agenteval.junit5.annotation; + +import com.agenteval.core.metric.EvalMetric; +import com.agenteval.junit5.extension.AgentEvalExtension; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Declares an evaluation metric to apply to an {@code @AgentTest} method. + * + *

Repeatable — multiple metrics can be applied to a single test method.

+ * + *
{@code
+ * @AgentTest
+ * @Metric(value = AnswerRelevancyMetric.class, threshold = 0.7)
+ * @Metric(value = FaithfulnessMetric.class, threshold = 0.8)
+ * void testQuality(AgentTestCase testCase) { ... }
+ * }
+ * + *

A threshold of {@code -1.0} means "use the metric's default threshold".

+ */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Repeatable(Metrics.class) +@ExtendWith(AgentEvalExtension.class) +public @interface Metric { + + /** + * The metric class to instantiate. + */ + Class value(); + + /** + * Score threshold (0.0–1.0). Use {@code -1.0} to defer to the metric's default. + */ + double threshold() default -1.0; +} diff --git a/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/Metrics.java b/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/Metrics.java new file mode 100644 index 0000000..65e11ba --- /dev/null +++ b/agenteval-junit5/src/main/java/com/agenteval/junit5/annotation/Metrics.java @@ -0,0 +1,19 @@ +package com.agenteval.junit5.annotation; + +import com.agenteval.junit5.extension.AgentEvalExtension; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Container annotation for repeated {@link Metric} annotations. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(AgentEvalExtension.class) +public @interface Metrics { + Metric[] value(); +} diff --git a/agenteval-junit5/src/main/java/com/agenteval/junit5/assertion/AgentAssertions.java b/agenteval-junit5/src/main/java/com/agenteval/junit5/assertion/AgentAssertions.java new file mode 100644 index 0000000..06359c5 --- /dev/null +++ b/agenteval-junit5/src/main/java/com/agenteval/junit5/assertion/AgentAssertions.java @@ -0,0 +1,33 @@ +package com.agenteval.junit5.assertion; + +import com.agenteval.core.model.AgentTestCase; + +/** + * Static entry point for fluent agent test case assertions. + * + *
{@code
+ * AgentAssertions.assertThat(testCase)
+ *     .meetsMetric(new AnswerRelevancyMetric(judge, 0.7))
+ *     .hasToolCalls()
+ *     .calledTool("SearchOrders")
+ *     .neverCalledTool("DeleteOrder")
+ *     .outputContains("refund");
+ * }
+ * + *

Works independently of {@code @AgentTest} and the extension — usable + * in any JUnit test.

+ */ +public final class AgentAssertions { + + private AgentAssertions() {} + + /** + * Begins a fluent assertion chain for the given test case. + * + * @param testCase the test case to assert against + * @return a fluent assertion object + */ + public static AgentTestCaseAssert assertThat(AgentTestCase testCase) { + return new AgentTestCaseAssert(testCase); + } +} diff --git a/agenteval-junit5/src/main/java/com/agenteval/junit5/assertion/AgentTestCaseAssert.java b/agenteval-junit5/src/main/java/com/agenteval/junit5/assertion/AgentTestCaseAssert.java new file mode 100644 index 0000000..625977c --- /dev/null +++ b/agenteval-junit5/src/main/java/com/agenteval/junit5/assertion/AgentTestCaseAssert.java @@ -0,0 +1,116 @@ +package com.agenteval.junit5.assertion; + +import com.agenteval.core.metric.EvalMetric; +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.core.model.EvalScore; + +import java.util.Objects; + +/** + * Fluent assertion chain for {@link AgentTestCase}. + * + *

Each assertion method evaluates immediately and throws {@link AssertionError} + * on failure, allowing standard JUnit 5 failure reporting.

+ */ +public final class AgentTestCaseAssert { + + private final AgentTestCase testCase; + + AgentTestCaseAssert(AgentTestCase testCase) { + this.testCase = Objects.requireNonNull(testCase, "testCase must not be null"); + } + + /** + * Evaluates the metric against the test case and asserts it passes. + */ + public AgentTestCaseAssert meetsMetric(EvalMetric metric) { + EvalScore score = metric.evaluate(testCase); + if (!score.passed()) { + throw new AssertionError(String.format( + "Metric '%s' failed: %.3f < %.3f (%s)", + metric.name(), score.value(), score.threshold(), score.reason())); + } + return this; + } + + /** + * Asserts that the test case has at least one tool call. + */ + public AgentTestCaseAssert hasToolCalls() { + if (testCase.getToolCalls().isEmpty()) { + throw new AssertionError("Expected tool calls but found none"); + } + return this; + } + + /** + * Asserts that a tool with the given name was called. + */ + public AgentTestCaseAssert calledTool(String toolName) { + boolean found = testCase.getToolCalls().stream() + .anyMatch(tc -> tc.name().equals(toolName)); + if (!found) { + throw new AssertionError("Expected tool '" + toolName + + "' to be called, but it was not. Actual tools: " + + testCase.getToolCalls().stream() + .map(tc -> tc.name()) + .toList()); + } + return this; + } + + /** + * Asserts that a tool with the given name was never called. + */ + public AgentTestCaseAssert neverCalledTool(String toolName) { + boolean found = testCase.getToolCalls().stream() + .anyMatch(tc -> tc.name().equals(toolName)); + if (found) { + throw new AssertionError("Expected tool '" + toolName + + "' to NOT be called, but it was"); + } + return this; + } + + /** + * Asserts that the actual output contains the given substring. + */ + public AgentTestCaseAssert outputContains(String substring) { + String actual = testCase.getActualOutput(); + if (actual == null || !actual.contains(substring)) { + throw new AssertionError("Expected actual output to contain '" + + substring + "', but was: " + actual); + } + return this; + } + + /** + * Asserts that the actual output does not contain the given substring. + */ + public AgentTestCaseAssert outputDoesNotContain(String substring) { + String actual = testCase.getActualOutput(); + if (actual != null && actual.contains(substring)) { + throw new AssertionError("Expected actual output to NOT contain '" + + substring + "'"); + } + return this; + } + + /** + * Asserts that the actual output is not null and not empty. + */ + public AgentTestCaseAssert hasOutput() { + String actual = testCase.getActualOutput(); + if (actual == null || actual.isEmpty()) { + throw new AssertionError("Expected non-empty actual output, but was: " + actual); + } + return this; + } + + /** + * Returns the underlying test case for further inspection. + */ + public AgentTestCase testCase() { + return testCase; + } +} diff --git a/agenteval-junit5/src/main/java/com/agenteval/junit5/extension/AgentEvalExtension.java b/agenteval-junit5/src/main/java/com/agenteval/junit5/extension/AgentEvalExtension.java new file mode 100644 index 0000000..f1bdfa1 --- /dev/null +++ b/agenteval-junit5/src/main/java/com/agenteval/junit5/extension/AgentEvalExtension.java @@ -0,0 +1,174 @@ +package com.agenteval.junit5.extension; + +import com.agenteval.core.config.AgentEvalConfig; +import com.agenteval.core.judge.JudgeModel; +import com.agenteval.core.metric.EvalMetric; +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.core.model.EvalScore; +import com.agenteval.junit5.annotation.Metric; +import com.agenteval.junit5.annotation.Metrics; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Namespace; +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolver; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +/** + * JUnit 5 extension that provides {@link AgentTestCase} parameter resolution, + * captures test case instances via invocation interception, and evaluates + * declared {@link Metric} annotations after each test. + * + *

Registered automatically via {@code @AgentTest}, {@code @Metric}, + * or {@code @Metrics} meta-annotations. Can also be registered explicitly:

+ * + *
{@code
+ * @RegisterExtension
+ * static AgentEvalExtension ext = AgentEvalExtension.withConfig(config);
+ * }
+ */ +public class AgentEvalExtension + implements ParameterResolver, InvocationInterceptor, AfterEachCallback { + + private static final Logger LOG = LoggerFactory.getLogger(AgentEvalExtension.class); + private static final Namespace NS = Namespace.create(AgentEvalExtension.class); + private static final String TEST_CASE_KEY = "agentTestCase"; + + private final AgentEvalConfig config; + + /** + * Default constructor used by {@code @ExtendWith} (via meta-annotations). + */ + public AgentEvalExtension() { + this(null); + } + + private AgentEvalExtension(AgentEvalConfig config) { + this.config = config; + } + + /** + * Creates an extension with explicit configuration (for {@code @RegisterExtension}). + */ + public static AgentEvalExtension withConfig(AgentEvalConfig config) { + return new AgentEvalExtension(config); + } + + // --- ParameterResolver --- + + @Override + public boolean supportsParameter(ParameterContext parameterContext, + ExtensionContext extensionContext) { + return parameterContext.getParameter().getType() == AgentTestCase.class; + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, + ExtensionContext extensionContext) { + var store = extensionContext.getStore(NS); + AgentTestCase testCase = store.get(TEST_CASE_KEY, AgentTestCase.class); + if (testCase == null) { + testCase = AgentTestCase.builder().input("").build(); + store.put(TEST_CASE_KEY, testCase); + } + return testCase; + } + + // --- InvocationInterceptor --- + + @Override + public void interceptTestMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + captureTestCase(invocationContext, extensionContext); + invocation.proceed(); + } + + @Override + public void interceptTestTemplateMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + captureTestCase(invocationContext, extensionContext); + invocation.proceed(); + } + + private void captureTestCase(ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) { + for (Object arg : invocationContext.getArguments()) { + if (arg instanceof AgentTestCase tc) { + extensionContext.getStore(NS).put(TEST_CASE_KEY, tc); + return; + } + } + } + + // --- AfterEachCallback --- + + @Override + public void afterEach(ExtensionContext context) { + List metricAnnotations = findMetricAnnotations(context); + if (metricAnnotations.isEmpty()) return; + + AgentTestCase testCase = context.getStore(NS).get(TEST_CASE_KEY, AgentTestCase.class); + if (testCase == null) { + LOG.warn("No AgentTestCase found in store; skipping metric evaluation"); + return; + } + + JudgeModel judgeModel = resolveJudgeModel(); + List failures = new ArrayList<>(); + + for (Metric metricAnn : metricAnnotations) { + EvalMetric metric = MetricFactory.create( + metricAnn.value(), metricAnn.threshold(), judgeModel); + EvalScore score = metric.evaluate(testCase); + score = score.withMetricName(metric.name()); + + LOG.debug("Metric '{}': score={}, threshold={}, passed={}", + metric.name(), score.value(), score.threshold(), score.passed()); + + if (!score.passed()) { + failures.add(String.format("%s: %.3f < %.3f (%s)", + metric.name(), score.value(), score.threshold(), score.reason())); + } + } + + if (!failures.isEmpty()) { + throw new AssertionError("Metric evaluation failed:\n " + + String.join("\n ", failures)); + } + } + + private List findMetricAnnotations(ExtensionContext context) { + return context.getTestMethod() + .map(method -> { + List result = new ArrayList<>(); + // Direct @Metric annotations + Metric single = method.getAnnotation(Metric.class); + if (single != null) { + result.add(single); + } + // @Metrics container + Metrics container = method.getAnnotation(Metrics.class); + if (container != null) { + result.addAll(List.of(container.value())); + } + return result; + }) + .orElse(List.of()); + } + + private JudgeModel resolveJudgeModel() { + if (config != null) { + return config.judgeModel(); + } + return null; + } +} diff --git a/agenteval-junit5/src/main/java/com/agenteval/junit5/extension/DatasetArgumentsProvider.java b/agenteval-junit5/src/main/java/com/agenteval/junit5/extension/DatasetArgumentsProvider.java new file mode 100644 index 0000000..3fcb41e --- /dev/null +++ b/agenteval-junit5/src/main/java/com/agenteval/junit5/extension/DatasetArgumentsProvider.java @@ -0,0 +1,40 @@ +package com.agenteval.junit5.extension; + +import com.agenteval.datasets.DatasetException; +import com.agenteval.datasets.EvalDataset; +import com.agenteval.datasets.json.JsonDatasetLoader; +import com.agenteval.junit5.annotation.DatasetSource; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.support.AnnotationConsumer; + +import java.io.InputStream; +import java.util.stream.Stream; + +/** + * JUnit 5 {@link ArgumentsProvider} that loads {@code AgentTestCase} instances + * from a JSON dataset specified by {@link DatasetSource}. + */ +public final class DatasetArgumentsProvider + implements ArgumentsProvider, AnnotationConsumer { + + private String resourcePath; + + @Override + public void accept(DatasetSource annotation) { + this.resourcePath = annotation.value(); + } + + @Override + public Stream provideArguments(ExtensionContext context) { + InputStream is = Thread.currentThread().getContextClassLoader() + .getResourceAsStream(resourcePath); + if (is == null) { + throw new DatasetException("Dataset resource not found on classpath: " + resourcePath); + } + + EvalDataset dataset = new JsonDatasetLoader().load(is); + return dataset.getTestCases().stream().map(Arguments::of); + } +} diff --git a/agenteval-junit5/src/main/java/com/agenteval/junit5/extension/MetricFactory.java b/agenteval-junit5/src/main/java/com/agenteval/junit5/extension/MetricFactory.java new file mode 100644 index 0000000..1d827fe --- /dev/null +++ b/agenteval-junit5/src/main/java/com/agenteval/junit5/extension/MetricFactory.java @@ -0,0 +1,104 @@ +package com.agenteval.junit5.extension; + +import com.agenteval.core.judge.JudgeModel; +import com.agenteval.core.metric.EvalMetric; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Constructor; + +/** + * Reflectively instantiates {@link EvalMetric} implementations from annotation metadata. + * + *

Tries constructors in order:

+ *
    + *
  1. {@code (JudgeModel, double)} — LLM metric with explicit threshold
  2. + *
  3. {@code (double)} — deterministic metric with threshold
  4. + *
  5. {@code (JudgeModel)} — LLM metric with default threshold
  6. + *
  7. {@code ()} — no-arg (deterministic metrics)
  8. + *
+ * + *

A threshold of {@code -1.0} means "use metric default" — the threshold parameter + * is skipped and constructors without a threshold parameter are preferred.

+ */ +public final class MetricFactory { + + private static final Logger LOG = LoggerFactory.getLogger(MetricFactory.class); + private static final double USE_DEFAULT_THRESHOLD = -1.0; + + private MetricFactory() {} + + /** + * Creates a metric instance from its class and configuration. + * + * @param metricClass the metric class + * @param threshold the threshold (-1.0 for metric default) + * @param judgeModel the judge model (may be null for deterministic metrics) + * @return a new metric instance + * @throws MetricInstantiationException if no suitable constructor is found + */ + public static EvalMetric create(Class metricClass, + double threshold, + JudgeModel judgeModel) { + boolean useDefault = Double.compare(threshold, USE_DEFAULT_THRESHOLD) == 0; + + // Try (JudgeModel, double) if we have both + if (judgeModel != null && !useDefault) { + EvalMetric m = tryConstruct(metricClass, new Class[]{JudgeModel.class, double.class}, + judgeModel, threshold); + if (m != null) return m; + } + + // Try (double) if threshold is explicit + if (!useDefault) { + EvalMetric m = tryConstruct(metricClass, new Class[]{double.class}, threshold); + if (m != null) return m; + } + + // Try (JudgeModel) if available + if (judgeModel != null) { + EvalMetric m = tryConstruct(metricClass, new Class[]{JudgeModel.class}, judgeModel); + if (m != null) return m; + } + + // Try no-arg + EvalMetric m = tryConstruct(metricClass, new Class[]{}); + if (m != null) return m; + + throw new MetricInstantiationException( + "No suitable constructor found for " + metricClass.getName() + + ". Expected one of: (JudgeModel, double), (double), (JudgeModel), ()"); + } + + @SuppressWarnings("unchecked") + private static EvalMetric tryConstruct(Class clazz, + Class[] paramTypes, + Object... args) { + try { + Constructor ctor = clazz.getDeclaredConstructor(paramTypes); + ctor.setAccessible(true); + return ctor.newInstance(args); + } catch (NoSuchMethodException e) { + LOG.trace("Constructor {} not found on {}", paramTypes, clazz.getSimpleName()); + return null; + } catch (Exception e) { + throw new MetricInstantiationException( + "Failed to instantiate " + clazz.getName() + ": " + e.getMessage(), e); + } + } + + /** + * Exception thrown when metric instantiation fails. + */ + public static class MetricInstantiationException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public MetricInstantiationException(String message) { + super(message); + } + + public MetricInstantiationException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/agenteval-junit5/src/test/java/com/agenteval/junit5/assertion/AgentAssertionsTest.java b/agenteval-junit5/src/test/java/com/agenteval/junit5/assertion/AgentAssertionsTest.java new file mode 100644 index 0000000..ea9db48 --- /dev/null +++ b/agenteval-junit5/src/test/java/com/agenteval/junit5/assertion/AgentAssertionsTest.java @@ -0,0 +1,185 @@ +package com.agenteval.junit5.assertion; + +import com.agenteval.core.metric.EvalMetric; +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.core.model.EvalScore; +import com.agenteval.core.model.ToolCall; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AgentAssertionsTest { + + private static final EvalMetric PASSING_METRIC = new EvalMetric() { + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.of(0.9, 0.7, "good"); + } + + @Override + public String name() { return "PassingMetric"; } + }; + + private static final EvalMetric FAILING_METRIC = new EvalMetric() { + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.of(0.3, 0.7, "bad"); + } + + @Override + public String name() { return "FailingMetric"; } + }; + + @Test + void meetsMetricShouldPassForPassingMetric() { + var tc = AgentTestCase.builder().input("q").actualOutput("a").build(); + assertThatCode(() -> AgentAssertions.assertThat(tc).meetsMetric(PASSING_METRIC)) + .doesNotThrowAnyException(); + } + + @Test + void meetsMetricShouldFailForFailingMetric() { + var tc = AgentTestCase.builder().input("q").actualOutput("a").build(); + assertThatThrownBy(() -> AgentAssertions.assertThat(tc).meetsMetric(FAILING_METRIC)) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("FailingMetric"); + } + + @Test + void hasToolCallsShouldPassWhenToolCallsExist() { + var tc = AgentTestCase.builder() + .input("q") + .toolCalls(List.of(ToolCall.of("search"))) + .build(); + assertThatCode(() -> AgentAssertions.assertThat(tc).hasToolCalls()) + .doesNotThrowAnyException(); + } + + @Test + void hasToolCallsShouldFailWhenEmpty() { + var tc = AgentTestCase.builder().input("q").build(); + assertThatThrownBy(() -> AgentAssertions.assertThat(tc).hasToolCalls()) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("Expected tool calls"); + } + + @Test + void calledToolShouldPassWhenToolWasCalled() { + var tc = AgentTestCase.builder() + .input("q") + .toolCalls(List.of(ToolCall.of("search"), ToolCall.of("lookup"))) + .build(); + assertThatCode(() -> AgentAssertions.assertThat(tc).calledTool("search")) + .doesNotThrowAnyException(); + } + + @Test + void calledToolShouldFailWhenToolNotCalled() { + var tc = AgentTestCase.builder() + .input("q") + .toolCalls(List.of(ToolCall.of("search"))) + .build(); + assertThatThrownBy(() -> AgentAssertions.assertThat(tc).calledTool("delete")) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("delete"); + } + + @Test + void neverCalledToolShouldPassWhenToolNotCalled() { + var tc = AgentTestCase.builder() + .input("q") + .toolCalls(List.of(ToolCall.of("search"))) + .build(); + assertThatCode(() -> AgentAssertions.assertThat(tc).neverCalledTool("delete")) + .doesNotThrowAnyException(); + } + + @Test + void neverCalledToolShouldFailWhenToolWasCalled() { + var tc = AgentTestCase.builder() + .input("q") + .toolCalls(List.of(ToolCall.of("delete"))) + .build(); + assertThatThrownBy(() -> AgentAssertions.assertThat(tc).neverCalledTool("delete")) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("NOT be called"); + } + + @Test + void outputContainsShouldPassWhenContained() { + var tc = AgentTestCase.builder().input("q").actualOutput("refund policy applies").build(); + assertThatCode(() -> AgentAssertions.assertThat(tc).outputContains("refund")) + .doesNotThrowAnyException(); + } + + @Test + void outputContainsShouldFailWhenNotContained() { + var tc = AgentTestCase.builder().input("q").actualOutput("no match").build(); + assertThatThrownBy(() -> AgentAssertions.assertThat(tc).outputContains("refund")) + .isInstanceOf(AssertionError.class); + } + + @Test + void outputContainsShouldFailWhenOutputNull() { + var tc = AgentTestCase.builder().input("q").build(); + assertThatThrownBy(() -> AgentAssertions.assertThat(tc).outputContains("anything")) + .isInstanceOf(AssertionError.class); + } + + @Test + void outputDoesNotContainShouldPass() { + var tc = AgentTestCase.builder().input("q").actualOutput("hello").build(); + assertThatCode(() -> AgentAssertions.assertThat(tc).outputDoesNotContain("goodbye")) + .doesNotThrowAnyException(); + } + + @Test + void outputDoesNotContainShouldFail() { + var tc = AgentTestCase.builder().input("q").actualOutput("hello world").build(); + assertThatThrownBy(() -> AgentAssertions.assertThat(tc).outputDoesNotContain("hello")) + .isInstanceOf(AssertionError.class); + } + + @Test + void hasOutputShouldPassWhenOutputExists() { + var tc = AgentTestCase.builder().input("q").actualOutput("response").build(); + assertThatCode(() -> AgentAssertions.assertThat(tc).hasOutput()) + .doesNotThrowAnyException(); + } + + @Test + void hasOutputShouldFailWhenOutputNull() { + var tc = AgentTestCase.builder().input("q").build(); + assertThatThrownBy(() -> AgentAssertions.assertThat(tc).hasOutput()) + .isInstanceOf(AssertionError.class); + } + + @Test + void shouldSupportFluentChaining() { + var tc = AgentTestCase.builder() + .input("q") + .actualOutput("refund processed") + .toolCalls(List.of(ToolCall.of("SearchOrders"))) + .build(); + + assertThatCode(() -> + AgentAssertions.assertThat(tc) + .hasOutput() + .hasToolCalls() + .calledTool("SearchOrders") + .neverCalledTool("DeleteOrder") + .outputContains("refund") + .meetsMetric(PASSING_METRIC) + ).doesNotThrowAnyException(); + } + + @Test + void shouldReturnTestCase() { + var tc = AgentTestCase.builder().input("q").build(); + var result = AgentAssertions.assertThat(tc).testCase(); + org.assertj.core.api.Assertions.assertThat(result).isSameAs(tc); + } +} diff --git a/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/AgentEvalExtensionTest.java b/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/AgentEvalExtensionTest.java new file mode 100644 index 0000000..49b54bc --- /dev/null +++ b/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/AgentEvalExtensionTest.java @@ -0,0 +1,69 @@ +package com.agenteval.junit5.extension; + +import com.agenteval.core.metric.EvalMetric; +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.core.model.EvalScore; +import com.agenteval.junit5.annotation.AgentTest; +import com.agenteval.junit5.annotation.Metric; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AgentEvalExtensionTest { + + /** + * A simple deterministic metric for testing the extension lifecycle. + */ + public static final class AlwaysPassMetric implements EvalMetric { + AlwaysPassMetric() {} + + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.of(1.0, 0.5, "always passes"); + } + + @Override + public String name() { return "AlwaysPass"; } + } + + public static final class AlwaysFailMetric implements EvalMetric { + AlwaysFailMetric() {} + + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.of(0.0, 0.5, "always fails"); + } + + @Override + public String name() { return "AlwaysFail"; } + } + + @Nested + class ParameterResolution { + + @AgentTest + void shouldResolveAgentTestCase(AgentTestCase testCase) { + assertThat(testCase).isNotNull(); + testCase.setActualOutput("resolved via extension"); + assertThat(testCase.getActualOutput()).isEqualTo("resolved via extension"); + } + } + + @Nested + class MetricEvaluation { + + @AgentTest + @Metric(value = AlwaysPassMetric.class) + void shouldPassWithPassingMetric(AgentTestCase testCase) { + testCase.setActualOutput("some output"); + } + } + + @Test + void shouldCreateExtensionWithConfig() { + var ext = AgentEvalExtension.withConfig( + com.agenteval.core.config.AgentEvalConfig.defaults()); + assertThat(ext).isNotNull(); + } +} diff --git a/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/DatasetArgumentsProviderTest.java b/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/DatasetArgumentsProviderTest.java new file mode 100644 index 0000000..5d7d0d7 --- /dev/null +++ b/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/DatasetArgumentsProviderTest.java @@ -0,0 +1,47 @@ +package com.agenteval.junit5.extension; + +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.datasets.DatasetException; +import com.agenteval.junit5.annotation.DatasetSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import java.lang.annotation.Annotation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +class DatasetArgumentsProviderTest { + + @Test + void shouldLoadDatasetFromClasspath() { + var provider = new DatasetArgumentsProvider(); + provider.accept(datasetSource("test-dataset.json")); + + var args = provider.provideArguments(mock(ExtensionContext.class)).toList(); + + assertThat(args).hasSize(2); + AgentTestCase first = (AgentTestCase) args.get(0).get()[0]; + assertThat(first.getInput()).isEqualTo("What is the return policy?"); + } + + @Test + void shouldThrowWhenResourceNotFound() { + var provider = new DatasetArgumentsProvider(); + provider.accept(datasetSource("nonexistent.json")); + + assertThatThrownBy(() -> provider.provideArguments(mock(ExtensionContext.class)).toList()) + .isInstanceOf(DatasetException.class) + .hasMessageContaining("not found"); + } + + private DatasetSource datasetSource(String path) { + return new DatasetSource() { + @Override + public String value() { return path; } + + @Override + public Class annotationType() { return DatasetSource.class; } + }; + } +} diff --git a/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/MetricFactoryTest.java b/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/MetricFactoryTest.java new file mode 100644 index 0000000..d9ec649 --- /dev/null +++ b/agenteval-junit5/src/test/java/com/agenteval/junit5/extension/MetricFactoryTest.java @@ -0,0 +1,134 @@ +package com.agenteval.junit5.extension; + +import com.agenteval.core.judge.JudgeModel; +import com.agenteval.core.judge.JudgeResponse; +import com.agenteval.core.metric.EvalMetric; +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.core.model.EvalScore; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MetricFactoryTest { + + // --- Test metric implementations with various constructors --- + + public static final class NoArgMetric implements EvalMetric { + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.of(1.0, 0.5, "no-arg"); + } + + @Override + public String name() { return "NoArg"; } + } + + public static final class ThresholdMetric implements EvalMetric { + private final double threshold; + + ThresholdMetric(double threshold) { + this.threshold = threshold; + } + + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.of(0.8, threshold, "threshold"); + } + + @Override + public String name() { return "Threshold"; } + } + + public static final class JudgeMetric implements EvalMetric { + private final JudgeModel judge; + + JudgeMetric(JudgeModel judge) { + this.judge = judge; + } + + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.of(0.9, 0.5, "judge: " + judge.modelId()); + } + + @Override + public String name() { return "Judge"; } + } + + public static final class FullMetric implements EvalMetric { + private final JudgeModel judge; + private final double threshold; + + FullMetric(JudgeModel judge, double threshold) { + this.judge = judge; + this.threshold = threshold; + } + + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.of(0.7, threshold, "full: " + judge.modelId()); + } + + @Override + public String name() { return "Full"; } + } + + private static final JudgeModel MOCK_JUDGE = new JudgeModel() { + @Override + public JudgeResponse judge(String prompt) { return null; } + + @Override + public String modelId() { return "test-model"; } + }; + + @Test + void shouldCreateNoArgMetric() { + EvalMetric metric = MetricFactory.create(NoArgMetric.class, -1.0, null); + assertThat(metric).isInstanceOf(NoArgMetric.class); + assertThat(metric.name()).isEqualTo("NoArg"); + } + + @Test + void shouldCreateThresholdMetric() { + EvalMetric metric = MetricFactory.create(ThresholdMetric.class, 0.8, null); + assertThat(metric).isInstanceOf(ThresholdMetric.class); + } + + @Test + void shouldCreateJudgeMetric() { + EvalMetric metric = MetricFactory.create(JudgeMetric.class, -1.0, MOCK_JUDGE); + assertThat(metric).isInstanceOf(JudgeMetric.class); + } + + @Test + void shouldCreateFullMetric() { + EvalMetric metric = MetricFactory.create(FullMetric.class, 0.7, MOCK_JUDGE); + assertThat(metric).isInstanceOf(FullMetric.class); + } + + @Test + void shouldFallbackToNoArgWhenDefaultThreshold() { + EvalMetric metric = MetricFactory.create(NoArgMetric.class, -1.0, MOCK_JUDGE); + assertThat(metric).isInstanceOf(NoArgMetric.class); + } + + @Test + void shouldThrowWhenNoSuitableConstructor() { + assertThatThrownBy(() -> MetricFactory.create(NoConstructorMetric.class, 0.5, null)) + .isInstanceOf(MetricFactory.MetricInstantiationException.class) + .hasMessageContaining("No suitable constructor"); + } + + public static final class NoConstructorMetric implements EvalMetric { + NoConstructorMetric(String unsupported) {} + + @Override + public EvalScore evaluate(AgentTestCase testCase) { + return EvalScore.pass("never"); + } + + @Override + public String name() { return "NoConstructor"; } + } +} diff --git a/agenteval-junit5/src/test/resources/test-dataset.json b/agenteval-junit5/src/test/resources/test-dataset.json new file mode 100644 index 0000000..28ad44d --- /dev/null +++ b/agenteval-junit5/src/test/resources/test-dataset.json @@ -0,0 +1,13 @@ +{ + "name": "junit5-test-set", + "testCases": [ + { + "input": "What is the return policy?", + "expectedOutput": "You can return items within 30 days." + }, + { + "input": "How do I contact support?", + "expectedOutput": "Email support@example.com or call 1-800-SUPPORT." + } + ] +} diff --git a/agenteval-reporting/pom.xml b/agenteval-reporting/pom.xml new file mode 100644 index 0000000..e987d48 --- /dev/null +++ b/agenteval-reporting/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + + com.agenteval + agenteval-parent + 0.1.0-SNAPSHOT + + + agenteval-reporting + AgentEval Reporting + Evaluation result reporters: console, JUnit XML, and more + + + + com.agenteval + agenteval-core + + + org.slf4j + slf4j-api + + + diff --git a/agenteval-reporting/src/main/java/com/agenteval/reporting/ConsoleReporter.java b/agenteval-reporting/src/main/java/com/agenteval/reporting/ConsoleReporter.java new file mode 100644 index 0000000..9ec36ab --- /dev/null +++ b/agenteval-reporting/src/main/java/com/agenteval/reporting/ConsoleReporter.java @@ -0,0 +1,98 @@ +package com.agenteval.reporting; + +import com.agenteval.core.eval.CaseResult; +import com.agenteval.core.eval.EvalResult; +import com.agenteval.core.model.EvalScore; + +import java.io.PrintStream; +import java.util.Map; + +/** + * Reports evaluation results as a formatted table to a {@link PrintStream}. + * + *

Supports ANSI color output for terminal display.

+ */ +public final class ConsoleReporter implements EvalReporter { + + private static final String ANSI_RESET = "\u001B[0m"; + private static final String ANSI_GREEN = "\u001B[32m"; + private static final String ANSI_RED = "\u001B[31m"; + private static final String ANSI_BOLD = "\u001B[1m"; + + private final PrintStream out; + private final boolean ansiColors; + + public ConsoleReporter() { + this(System.out, true); + } + + public ConsoleReporter(PrintStream out, boolean ansiColors) { + this.out = out; + this.ansiColors = ansiColors; + } + + @Override + public void report(EvalResult result) { + int total = result.caseResults().size(); + int failed = result.failedCases().size(); + int passed = total - failed; + + printBold("=== AgentEval Results ==="); + out.printf("Cases: %d | Passed: %d | Failed: %d | Pass Rate: %.1f%%%n", + total, passed, failed, result.passRate() * 100); + out.printf("Average Score: %.3f | Duration: %dms%n", + result.averageScore(), result.durationMs()); + + Map byMetric = result.averageScoresByMetric(); + if (!byMetric.isEmpty()) { + out.println("--- Per-Metric Averages ---"); + byMetric.forEach((name, avg) -> { + String icon = avg >= 0.5 ? passIcon() : failIcon(); + out.printf(" %-30s %.3f %s%n", name, avg, icon); + }); + } + + if (!result.failedCases().isEmpty()) { + out.println("--- Failed Cases ---"); + for (CaseResult cr : result.failedCases()) { + String input = truncate(cr.testCase().getInput(), 40); + for (EvalScore score : cr.failedScores()) { + out.printf(" Case \"%s\" %s %s: %.2f < %.2f (%s)%n", + input, + ansiColors ? ANSI_RED : "", + score.metricName(), + score.value(), + score.threshold(), + score.reason() + (ansiColors ? ANSI_RESET : "")); + } + } + } + } + + private void printBold(String text) { + if (ansiColors) { + out.println(ANSI_BOLD + text + ANSI_RESET); + } else { + out.println(text); + } + } + + private String passIcon() { + if (ansiColors) { + return ANSI_GREEN + "\u2713" + ANSI_RESET; + } + return "PASS"; + } + + private String failIcon() { + if (ansiColors) { + return ANSI_RED + "\u2717" + ANSI_RESET; + } + return "FAIL"; + } + + private static String truncate(String s, int max) { + if (s == null) return ""; + return s.length() <= max ? s : s.substring(0, max - 3) + "..."; + } +} diff --git a/agenteval-reporting/src/main/java/com/agenteval/reporting/EvalReporter.java b/agenteval-reporting/src/main/java/com/agenteval/reporting/EvalReporter.java new file mode 100644 index 0000000..ac0f1ee --- /dev/null +++ b/agenteval-reporting/src/main/java/com/agenteval/reporting/EvalReporter.java @@ -0,0 +1,17 @@ +package com.agenteval.reporting; + +import com.agenteval.core.eval.EvalResult; + +/** + * Interface for generating evaluation reports. + */ +public interface EvalReporter { + + /** + * Generates a report for the given evaluation result. + * + * @param result the evaluation result to report + * @throws ReportException if report generation fails + */ + void report(EvalResult result); +} diff --git a/agenteval-reporting/src/main/java/com/agenteval/reporting/JunitXmlReporter.java b/agenteval-reporting/src/main/java/com/agenteval/reporting/JunitXmlReporter.java new file mode 100644 index 0000000..fd30ce2 --- /dev/null +++ b/agenteval-reporting/src/main/java/com/agenteval/reporting/JunitXmlReporter.java @@ -0,0 +1,104 @@ +package com.agenteval.reporting; + +import com.agenteval.core.eval.CaseResult; +import com.agenteval.core.eval.EvalResult; +import com.agenteval.core.model.EvalScore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Map; + +/** + * Generates JUnit XML reports compatible with CI systems (Jenkins, GitHub Actions, GitLab CI). + * + *

Each (metric x test case) pair becomes a {@code } element. + * Failing metric scores map to {@code } elements.

+ */ +public final class JunitXmlReporter implements EvalReporter { + + private static final Logger LOG = LoggerFactory.getLogger(JunitXmlReporter.class); + + private final Path outputPath; + + public JunitXmlReporter(Path outputPath) { + this.outputPath = outputPath; + } + + @Override + public void report(EvalResult result) { + try { + String xml = generateXml(result); + Files.writeString(outputPath, xml); + LOG.info("JUnit XML report written to {}", outputPath); + } catch (Exception e) { + throw new ReportException("Failed to write JUnit XML report to " + outputPath, e); + } + } + + /** + * Generates the JUnit XML string (visible for testing). + */ + String generateXml(EvalResult result) + throws ParserConfigurationException, TransformerException { + var docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder(); + var doc = docBuilder.newDocument(); + + var testsuite = doc.createElement("testsuite"); + testsuite.setAttribute("name", "AgentEval"); + testsuite.setAttribute("time", + String.format(Locale.US, "%.3f", result.durationMs() / 1000.0)); + + int tests = 0; + int failures = 0; + + for (CaseResult cr : result.caseResults()) { + String caseName = cr.testCase().getInput(); + for (Map.Entry entry : cr.scores().entrySet()) { + tests++; + var testcase = doc.createElement("testcase"); + testcase.setAttribute("classname", entry.getKey()); + testcase.setAttribute("name", caseName); + testcase.setAttribute("time", "0.000"); + + EvalScore score = entry.getValue(); + if (!score.passed()) { + failures++; + var failure = doc.createElement("failure"); + failure.setAttribute("message", + String.format(Locale.US, "%.3f < %.3f", + score.value(), score.threshold())); + failure.setAttribute("type", "MetricFailure"); + failure.setTextContent(score.reason()); + testcase.appendChild(failure); + } + + testsuite.appendChild(testcase); + } + } + + testsuite.setAttribute("tests", String.valueOf(tests)); + testsuite.setAttribute("failures", String.valueOf(failures)); + testsuite.setAttribute("errors", "0"); + doc.appendChild(testsuite); + + var transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); + + var writer = new StringWriter(); + transformer.transform(new DOMSource(doc), new StreamResult(writer)); + return writer.toString(); + } +} diff --git a/agenteval-reporting/src/main/java/com/agenteval/reporting/ReportException.java b/agenteval-reporting/src/main/java/com/agenteval/reporting/ReportException.java new file mode 100644 index 0000000..810eafc --- /dev/null +++ b/agenteval-reporting/src/main/java/com/agenteval/reporting/ReportException.java @@ -0,0 +1,17 @@ +package com.agenteval.reporting; + +/** + * Unchecked exception for report generation errors. + */ +public class ReportException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public ReportException(String message) { + super(message); + } + + public ReportException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/agenteval-reporting/src/test/java/com/agenteval/reporting/ConsoleReporterTest.java b/agenteval-reporting/src/test/java/com/agenteval/reporting/ConsoleReporterTest.java new file mode 100644 index 0000000..741dd37 --- /dev/null +++ b/agenteval-reporting/src/test/java/com/agenteval/reporting/ConsoleReporterTest.java @@ -0,0 +1,92 @@ +package com.agenteval.reporting; + +import com.agenteval.core.eval.CaseResult; +import com.agenteval.core.eval.EvalResult; +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.core.model.EvalScore; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConsoleReporterTest { + + @Test + void shouldReportPassingResults() { + var result = buildResult(0.85, 0.7, true); + String output = capture(result, false); + + assertThat(output).contains("=== AgentEval Results ==="); + assertThat(output).contains("Cases: 1"); + assertThat(output).contains("Passed: 1"); + assertThat(output).contains("Failed: 0"); + assertThat(output).contains("Pass Rate: 100.0%"); + assertThat(output).contains("Average Score:"); + assertThat(output).doesNotContain("--- Failed Cases ---"); + } + + @Test + void shouldReportFailingResults() { + var result = buildResult(0.45, 0.7, false); + String output = capture(result, false); + + assertThat(output).contains("Failed: 1"); + assertThat(output).contains("--- Failed Cases ---"); + assertThat(output).contains("test-metric"); + assertThat(output).contains("0.45"); + } + + @Test + void shouldReportPerMetricAverages() { + var result = buildResult(0.85, 0.7, true); + String output = capture(result, false); + + assertThat(output).contains("--- Per-Metric Averages ---"); + assertThat(output).contains("test-metric"); + } + + @Test + void shouldRespectAnsiColorsFalse() { + var result = buildResult(0.85, 0.7, true); + String output = capture(result, false); + + assertThat(output).doesNotContain("\u001B["); + assertThat(output).contains("PASS"); + } + + @Test + void shouldUseAnsiColorsWhenEnabled() { + var result = buildResult(0.85, 0.7, true); + String output = capture(result, true); + + assertThat(output).contains("\u001B["); + } + + @Test + void shouldHandleEmptyResults() { + var result = EvalResult.of(List.of(), 0); + String output = capture(result, false); + + assertThat(output).contains("Cases: 0"); + assertThat(output).contains("Pass Rate: 0.0%"); + } + + private String capture(EvalResult result, boolean ansi) { + var baos = new ByteArrayOutputStream(); + var reporter = new ConsoleReporter(new PrintStream(baos), ansi); + reporter.report(result); + return baos.toString(StandardCharsets.UTF_8); + } + + private EvalResult buildResult(double score, double threshold, boolean passed) { + var tc = AgentTestCase.builder().input("test input").actualOutput("test output").build(); + var evalScore = new EvalScore(score, threshold, passed, "test reason", "test-metric"); + var caseResult = new CaseResult(tc, Map.of("test-metric", evalScore), passed); + return EvalResult.of(List.of(caseResult), 1234); + } +} diff --git a/agenteval-reporting/src/test/java/com/agenteval/reporting/JunitXmlReporterTest.java b/agenteval-reporting/src/test/java/com/agenteval/reporting/JunitXmlReporterTest.java new file mode 100644 index 0000000..3ca6813 --- /dev/null +++ b/agenteval-reporting/src/test/java/com/agenteval/reporting/JunitXmlReporterTest.java @@ -0,0 +1,91 @@ +package com.agenteval.reporting; + +import com.agenteval.core.eval.CaseResult; +import com.agenteval.core.eval.EvalResult; +import com.agenteval.core.model.AgentTestCase; +import com.agenteval.core.model.EvalScore; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class JunitXmlReporterTest { + + @Test + void shouldGenerateValidXml(@TempDir Path tempDir) throws Exception { + var tc = AgentTestCase.builder() + .input("How do I get a refund?") + .actualOutput("You can request a refund.") + .build(); + var score = new EvalScore(0.85, 0.7, true, "Good answer", "Relevancy"); + var caseResult = new CaseResult(tc, Map.of("Relevancy", score), true); + var result = EvalResult.of(List.of(caseResult), 500); + + Path outFile = tempDir.resolve("report.xml"); + var reporter = new JunitXmlReporter(outFile); + reporter.report(result); + + String xml = Files.readString(outFile); + assertThat(xml).contains("agenteval-core agenteval-judge agenteval-metrics + agenteval-datasets + agenteval-reporting + agenteval-junit5 @@ -92,6 +95,21 @@ agenteval-judge ${project.version} + + com.agenteval + agenteval-metrics + ${project.version} + + + com.agenteval + agenteval-datasets + ${project.version} + + + com.agenteval + agenteval-reporting + ${project.version} + diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index e81e4f8..9ad3b3f 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -20,4 +20,28 @@ + + + + + + + + + + + + + + + + + + + + + + + +