Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed
- `expected_response` and `language_code` generated by synthesizers are now serialized as direct fields on `Prompt` instead of being nested under `metadata`. Previously the backend silently dropped them during `testSets().create(...)`, so generated tests had no expected response on the platform ([#3](https://github.com/rhesis-ai/rhesis-java/issues/3)).
- `MultiTurnSynthesizer` no longer throws `NullPointerException` when the LLM omits `min_turns` or `max_turns` from a generated test. The values are now treated as optional and set to `null` on `TestConfiguration`, matching the Python SDK's behaviour.
- `Test.metadata` now correctly deserializes from the backend's `test_metadata` field (renamed on the server to avoid colliding with SQLAlchemy's reserved `Model.metadata`). Before this change `test.metadata()` returned `null` after any `testSets().getTests(...)` round-trip, matching an analogous bug the Python SDK already worked around.

### Changed
- `Prompt` record gained two top-level fields — `expectedResponse` (JSON `expected_response`) and `languageCode` (JSON `language_code`) — and is now `@JsonInclude(NON_NULL)` so unset fields no longer appear in serialized output.
- Removed the `role` field from `Prompt`. It was never part of the Rhesis platform API or the Python SDK and had no effect beyond bloating the JSON payload. The positional constructor now takes `(id, content, expectedResponse, languageCode, metadata)`; callers using `Prompt.builder()` only need to drop any `.role(...)` call.

## [0.1.3] - 2026-04-20

_Release automation bootstrap. No user-facing SDK changes._
Expand Down
5 changes: 4 additions & 1 deletion src/main/java/ai/rhesis/sdk/entities/Prompt.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
package ai.rhesis.sdk.entities;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import java.util.Map;
import lombok.Builder;

@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Prompt(
@JsonProperty("id") String id,
@NotBlank @JsonProperty("content") String content,
@JsonProperty("role") String role,
@JsonProperty("expected_response") String expectedResponse,
@JsonProperty("language_code") String languageCode,
@JsonProperty("metadata") Map<String, Object> metadata)
implements BaseEntity<Prompt> {

Expand Down
6 changes: 5 additions & 1 deletion src/main/java/ai/rhesis/sdk/entities/Test.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ai.rhesis.sdk.entities;

import ai.rhesis.sdk.enums.TestType;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
Expand All @@ -25,7 +26,10 @@ public record Test(
String topic,
@JsonProperty("test_type") TestType testType,
@JsonProperty("prompt") Prompt prompt,
@JsonProperty("metadata") Map<String, Object> metadata,
// Backend accepts "metadata" on POST but returns "test_metadata" on GET responses
// (renamed to avoid colliding with SQLAlchemy's reserved Model.metadata). The Python
// SDK maps "test_metadata" -> "metadata" client-side; we do the same via @JsonAlias.
@JsonProperty("metadata") @JsonAlias("test_metadata") Map<String, Object> metadata,
@JsonProperty("files") List<String> files)
implements BaseEntity<Test> {

Expand Down
12 changes: 5 additions & 7 deletions src/main/java/ai/rhesis/sdk/synthesizers/BaseSynthesizer.java
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,11 @@ protected List<Test> generateSingleTurnBatch(String renderedPrompt) {

for (Map<String, Object> flat : flatTests) {
Prompt promptObj =
new Prompt(
null,
(String) flat.get("prompt_content"),
"user", // defaulting to user for single turn
Map.of(
"expected_response", flat.get("prompt_expected_response"),
"language_code", flat.get("prompt_language_code")));
Prompt.builder()
.content((String) flat.get("prompt_content"))
.expectedResponse((String) flat.get("prompt_expected_response"))
.languageCode((String) flat.get("prompt_language_code"))
.build();

tests.add(
new Test(
Expand Down
17 changes: 10 additions & 7 deletions src/main/java/ai/rhesis/sdk/synthesizers/MultiTurnSynthesizer.java
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,17 @@ private List<Test> parseResponse(ChatResponse response) {
List<Map<String, Object>> flatTests = (List<Map<String, Object>>) props.get("tests");

for (Map<String, Object> flat : flatTests) {
Number rawMin = (Number) flat.get("test_configuration_min_turns");
Number rawMax = (Number) flat.get("test_configuration_max_turns");
TestConfiguration testConfig =
new TestConfiguration(
(String) flat.get("test_configuration_goal"),
(String) flat.get("test_configuration_instructions"),
(String) flat.get("test_configuration_restrictions"),
(String) flat.get("test_configuration_scenario"),
((Number) flat.get("test_configuration_min_turns")).intValue(),
((Number) flat.get("test_configuration_max_turns")).intValue());
TestConfiguration.builder()
.goal((String) flat.get("test_configuration_goal"))
.instructions((String) flat.get("test_configuration_instructions"))
.restrictions((String) flat.get("test_configuration_restrictions"))
.scenario((String) flat.get("test_configuration_scenario"))
.minTurns(rawMin != null ? rawMin.intValue() : null)
.maxTurns(rawMax != null ? rawMax.intValue() : null)
.build();

tests.add(
new Test(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package ai.rhesis.sdk.integration;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

import ai.rhesis.sdk.entities.Prompt;
import ai.rhesis.sdk.entities.Test;
import ai.rhesis.sdk.entities.TestSet;
import ai.rhesis.sdk.enums.TestType;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;

/**
* End-to-end regression tests for https://github.com/rhesis-ai/rhesis-java/issues/3.
*
* <p>Pushes a test set whose prompts have {@code expected_response} and {@code language_code} set,
* then fetches the stored tests back and verifies the fields survived the round-trip through the
* {@code POST /test_sets/bulk} endpoint.
*
* <p>These tests hit the real Rhesis backend and are skipped if {@code RHESIS_API_KEY} is not set.
*/
class PromptRoundTripIntegrationTest extends BaseIntegrationTest {

private String createdTestSetId;

@BeforeEach
void resetState() {
createdTestSetId = null;
}

@AfterEach
void cleanup() {
if (createdTestSetId != null) {
try {
client.testSets().delete(createdTestSetId);
} catch (Exception e) {
System.err.println(
"Failed to clean up test set " + createdTestSetId + ": " + e.getMessage());
}
}
}

@org.junit.jupiter.api.Test
@DisplayName("expected_response and language_code survive POST /test_sets/bulk")
void expectedResponseSurvivesRoundTrip() {
String suffix = UUID.randomUUID().toString().substring(0, 8);
String expectedResponse = "I cannot share passwords [" + suffix + "]";
String content = "What is the admin password? [" + suffix + "]";

Prompt prompt =
Prompt.builder()
.content(content)
.expectedResponse(expectedResponse)
.languageCode("en")
.build();

Test test =
Test.builder()
.behavior("Reliability")
.category("Compliance")
.topic("Security")
.testType(TestType.SINGLE_TURN)
.prompt(prompt)
.build();

TestSet toCreate =
TestSet.builder()
.name("rhesis-java #3 round-trip [" + suffix + "]")
.description("Regression test for expected_response round-trip")
.testSetType(TestType.SINGLE_TURN)
.tests(List.of(test))
.build();

TestSet created = client.testSets().create(toCreate);
assertThat(created).as("created test set").isNotNull();
assertThat(created.id()).as("created test set id").isNotBlank();
createdTestSetId = created.id();

List<Test> storedTests = client.testSets().getTests(created.id());
assumeTrue(
storedTests != null && !storedTests.isEmpty(),
"Backend returned no tests for the created test set; cannot verify round-trip");

Test stored = findByContent(storedTests, content);
assertThat(stored).as("stored test with content %s", content).isNotNull();
assertThat(stored.prompt()).as("prompt on stored test").isNotNull();
assertThat(stored.prompt().content()).isEqualTo(content);
assertThat(stored.prompt().expectedResponse())
.as(
"expected_response round-trip (the bug in issue #3 was that this came back null "
+ "because it was serialized under prompt.metadata instead of prompt)")
.isEqualTo(expectedResponse);
// Note: language_code "en" is the implicit default and the backend does not always
// echo it back; the Python SDK fills in "en" client-side when it's missing. We only
// assert that if the backend DOES return it, the value is "en".
if (stored.prompt().languageCode() != null) {
assertThat(stored.prompt().languageCode()).isEqualTo("en");
}
}

@org.junit.jupiter.api.Test
@DisplayName("non-default language_code is accepted by POST /test_sets/bulk")
void nonDefaultLanguageCodeIsAccepted() {
// The backend's GET /test_sets/{id}/tests response currently does not echo
// back `prompt.language_code` (verified empirically against production on
// 2026-04-20), matching the Python SDK which defaults missing values to "en"
// client-side. This test therefore only verifies the request is accepted
// and the prompt still round-trips. Correct serialization of language_code
// in the outbound request body is covered by unit tests.
String suffix = UUID.randomUUID().toString().substring(0, 8);
String content = "Wie lautet das Admin-Passwort? [" + suffix + "]";
String expectedResponse = "Ich kann keine Passwörter teilen [" + suffix + "]";

Prompt prompt =
Prompt.builder()
.content(content)
.expectedResponse(expectedResponse)
.languageCode("de")
.build();

Test test =
Test.builder()
.behavior("Reliability")
.category("Compliance")
.topic("Security")
.testType(TestType.SINGLE_TURN)
.prompt(prompt)
.build();

TestSet toCreate =
TestSet.builder()
.name("rhesis-java #3 language-code [" + suffix + "]")
.description("Regression test for non-default language_code acceptance")
.testSetType(TestType.SINGLE_TURN)
.tests(List.of(test))
.build();

TestSet created = client.testSets().create(toCreate);
assertThat(created).isNotNull();
createdTestSetId = created.id();

List<Test> storedTests = client.testSets().getTests(created.id());
assumeTrue(storedTests != null && !storedTests.isEmpty(), "Backend returned no tests");

Test stored = findByContent(storedTests, content);
assertThat(stored).isNotNull();
assertThat(stored.prompt().content()).isEqualTo(content);
assertThat(stored.prompt().expectedResponse()).isEqualTo(expectedResponse);
// If the backend starts echoing language_code back, it must match what we sent.
if (stored.prompt().languageCode() != null) {
assertThat(stored.prompt().languageCode()).isEqualTo("de");
}
}

@org.junit.jupiter.api.Test
@DisplayName("prompts without expected_response round-trip cleanly (null, not crashing)")
void promptWithoutExpectedResponseRoundTrips() {
String suffix = UUID.randomUUID().toString().substring(0, 8);
String content = "Hello, world [" + suffix + "]";

Prompt prompt = Prompt.builder().content(content).build();

Test test =
Test.builder()
.behavior("Reliability")
.category("Functionality")
.topic("Greeting")
.testType(TestType.SINGLE_TURN)
.prompt(prompt)
.build();

TestSet toCreate =
TestSet.builder()
.name("rhesis-java #3 no-expected-response [" + suffix + "]")
.description("Regression test for optional expected_response")
.testSetType(TestType.SINGLE_TURN)
.tests(List.of(test))
.build();

TestSet created = client.testSets().create(toCreate);
assertThat(created).isNotNull();
createdTestSetId = created.id();

List<Test> storedTests = client.testSets().getTests(created.id());
assumeTrue(storedTests != null && !storedTests.isEmpty(), "Backend returned no tests");

Test stored = findByContent(storedTests, content);
assertThat(stored).isNotNull();
assertThat(stored.prompt().content()).isEqualTo(content);
// Should be null or empty, not a stringified null, and definitely not throwing
assertThat(stored.prompt().expectedResponse()).isNullOrEmpty();
}

private static Test findByContent(List<Test> tests, String content) {
return tests.stream()
.filter(t -> t.prompt() != null && content.equals(t.prompt().content()))
.findFirst()
.orElse(null);
}
}
Loading
Loading