feat(search): implement RestClientOllamaClient with WireMock tests
Switch to wiremock-jetty12 artifact and force ee10 Jetty deps to 12.1.8 to resolve compatibility with Spring Boot 4's Jetty 12.1.8 core. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,27 @@
|
|||||||
<type>pom</type>
|
<type>pom</type>
|
||||||
<scope>import</scope>
|
<scope>import</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Force WireMock's ee10 Jetty transitive deps to match Spring Boot's 12.1.8 core -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||||
|
<artifactId>jetty-ee10-servlet</artifactId>
|
||||||
|
<version>12.1.8</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||||
|
<artifactId>jetty-ee10-servlets</artifactId>
|
||||||
|
<version>12.1.8</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||||
|
<artifactId>jetty-ee10-webapp</artifactId>
|
||||||
|
<version>12.1.8</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.eclipse.jetty</groupId>
|
||||||
|
<artifactId>jetty-ee</artifactId>
|
||||||
|
<version>12.1.8</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
@@ -140,7 +161,7 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.wiremock</groupId>
|
<groupId>org.wiremock</groupId>
|
||||||
<artifactId>wiremock</artifactId>
|
<artifactId>wiremock-jetty12</artifactId>
|
||||||
<version>3.9.2</version>
|
<version>3.9.2</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package org.raddatz.familienarchiv.search;
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
public interface OllamaClient {
|
public interface OllamaClient {
|
||||||
NlQueryInterpretation parse(String query);
|
OllamaExtraction parse(String query);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw structured output from Ollama after parsing and sanitising.
|
||||||
|
* personRole is always one of "sender", "receiver", "any" — defensive parsing ensures this.
|
||||||
|
*/
|
||||||
|
record OllamaExtraction(
|
||||||
|
List<String> personNames,
|
||||||
|
String personRole,
|
||||||
|
LocalDate dateFrom,
|
||||||
|
LocalDate dateTo,
|
||||||
|
List<String> keywords,
|
||||||
|
String rawQuery
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
|
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.Year;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class RestClientOllamaClient implements OllamaClient, OllamaHealthClient {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final Set<String> VALID_ROLES = Set.of("sender", "receiver", "any");
|
||||||
|
private static final int MAX_NAME_LENGTH = 200;
|
||||||
|
private static final int MAX_KEYWORD_LENGTH = 100;
|
||||||
|
|
||||||
|
private static final Map<String, Object> JSON_SCHEMA = Map.of(
|
||||||
|
"type", "object",
|
||||||
|
"required", List.of("personNames", "personRole", "keywords"),
|
||||||
|
"properties", Map.of(
|
||||||
|
"personNames", Map.of("type", "array", "items", Map.of("type", "string", "maxLength", MAX_NAME_LENGTH)),
|
||||||
|
"personRole", Map.of("type", "string", "enum", List.of("sender", "receiver", "any")),
|
||||||
|
"dateFrom", Map.of("type", List.of("string", "null"), "maxLength", 20),
|
||||||
|
"dateTo", Map.of("type", List.of("string", "null"), "maxLength", 20),
|
||||||
|
"keywords", Map.of("type", "array", "items", Map.of("type", "string", "maxLength", MAX_KEYWORD_LENGTH))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
private final RestClient inferenceClient;
|
||||||
|
private final RestClient healthClient;
|
||||||
|
private final OllamaProperties props;
|
||||||
|
|
||||||
|
public RestClientOllamaClient(OllamaProperties props) {
|
||||||
|
this.props = props;
|
||||||
|
|
||||||
|
HttpClient inferenceHttp = HttpClient.newBuilder()
|
||||||
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
JdkClientHttpRequestFactory inferenceFactory = new JdkClientHttpRequestFactory(inferenceHttp);
|
||||||
|
inferenceFactory.setReadTimeout(Duration.ofSeconds(props.getTimeoutSeconds()));
|
||||||
|
this.inferenceClient = RestClient.builder()
|
||||||
|
.baseUrl(props.getBaseUrl())
|
||||||
|
.requestFactory(inferenceFactory)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpClient healthHttp = HttpClient.newBuilder()
|
||||||
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
|
.connectTimeout(Duration.ofSeconds(props.getHealthCheckTimeoutSeconds()))
|
||||||
|
.build();
|
||||||
|
JdkClientHttpRequestFactory healthFactory = new JdkClientHttpRequestFactory(healthHttp);
|
||||||
|
healthFactory.setReadTimeout(Duration.ofSeconds(props.getHealthCheckTimeoutSeconds()));
|
||||||
|
this.healthClient = RestClient.builder()
|
||||||
|
.baseUrl(props.getBaseUrl())
|
||||||
|
.requestFactory(healthFactory)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OllamaExtraction parse(String query) {
|
||||||
|
try {
|
||||||
|
OllamaGenerateRequest request = new OllamaGenerateRequest(
|
||||||
|
props.getModel(), query, JSON_SCHEMA, false);
|
||||||
|
String responseBody = inferenceClient.post()
|
||||||
|
.uri("/api/generate")
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.body(request)
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
return parseOllamaResponse(responseBody, query);
|
||||||
|
} catch (DomainException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Ollama inference failed: {}", e.getClass().getSimpleName());
|
||||||
|
throw DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_UNAVAILABLE,
|
||||||
|
"Ollama unavailable: " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isHealthy() {
|
||||||
|
try {
|
||||||
|
healthClient.get().uri("/api/tags").retrieve().toBodilessEntity();
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OllamaExtraction parseOllamaResponse(String responseBody, String rawQuery) {
|
||||||
|
try {
|
||||||
|
OllamaGenerateResponse response = MAPPER.readValue(responseBody, OllamaGenerateResponse.class);
|
||||||
|
String inner = response.response();
|
||||||
|
if (inner == null || inner.isBlank()) {
|
||||||
|
return fallbackExtraction(rawQuery);
|
||||||
|
}
|
||||||
|
RawOllamaOutput raw = MAPPER.readValue(inner, RawOllamaOutput.class);
|
||||||
|
return toExtraction(raw, rawQuery);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to parse Ollama response: {}", e.getClass().getSimpleName());
|
||||||
|
throw DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_UNAVAILABLE,
|
||||||
|
"Failed to parse Ollama response: " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OllamaExtraction toExtraction(RawOllamaOutput raw, String rawQuery) {
|
||||||
|
List<String> names = raw.personNames() == null ? List.of() : raw.personNames().stream()
|
||||||
|
.filter(n -> n != null && n.length() <= MAX_NAME_LENGTH)
|
||||||
|
.toList();
|
||||||
|
List<String> keywords = raw.keywords() == null ? List.of() : raw.keywords().stream()
|
||||||
|
.filter(k -> k != null && k.length() <= MAX_KEYWORD_LENGTH)
|
||||||
|
.toList();
|
||||||
|
String role = sanitiseRole(raw.personRole());
|
||||||
|
LocalDate dateFrom = parseDate(raw.dateFrom(), true);
|
||||||
|
LocalDate dateTo = parseDate(raw.dateTo(), false);
|
||||||
|
return new OllamaExtraction(names, role, dateFrom, dateTo, keywords, rawQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OllamaExtraction fallbackExtraction(String rawQuery) {
|
||||||
|
return new OllamaExtraction(List.of(), "any", null, null, List.of(), rawQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sanitiseRole(String role) {
|
||||||
|
if (role != null && VALID_ROLES.contains(role)) {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
log.warn("Unexpected personRole from Ollama: {}", role);
|
||||||
|
return "any";
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDate parseDate(String raw, boolean isFrom) {
|
||||||
|
if (raw == null || raw.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(raw, DateTimeFormatter.ISO_LOCAL_DATE);
|
||||||
|
} catch (DateTimeParseException ignored) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int year = Integer.parseInt(raw.strip());
|
||||||
|
if (year > 1000 && year < 3000) {
|
||||||
|
return isFrom ? Year.of(year).atDay(1) : Year.of(year).atMonth(12).atEndOfMonth();
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
private record OllamaGenerateResponse(String response) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
private record RawOllamaOutput(
|
||||||
|
@JsonProperty("personNames") List<String> personNames,
|
||||||
|
@JsonProperty("personRole") String personRole,
|
||||||
|
@JsonProperty("dateFrom") String dateFrom,
|
||||||
|
@JsonProperty("dateTo") String dateTo,
|
||||||
|
@JsonProperty("keywords") List<String> keywords
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record OllamaGenerateRequest(
|
||||||
|
String model,
|
||||||
|
String prompt,
|
||||||
|
Object format,
|
||||||
|
boolean stream
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
import com.github.tomakehurst.wiremock.WireMockServer;
|
||||||
|
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
|
||||||
|
import static com.github.tomakehurst.wiremock.client.WireMock.*;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class RestClientOllamaClientTest {
|
||||||
|
|
||||||
|
private WireMockServer wireMock;
|
||||||
|
private RestClientOllamaClient client;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
wireMock = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
|
||||||
|
wireMock.start();
|
||||||
|
|
||||||
|
OllamaProperties props = new OllamaProperties();
|
||||||
|
props.setBaseUrl("http://localhost:" + wireMock.port());
|
||||||
|
props.setModel("qwen2.5:7b-instruct-q4_K_M");
|
||||||
|
props.setTimeoutSeconds(5);
|
||||||
|
props.setHealthCheckTimeoutSeconds(2);
|
||||||
|
|
||||||
|
client = new RestClientOllamaClient(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
wireMock.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Factory helpers ---
|
||||||
|
|
||||||
|
private String makeOllamaResponseJson(String personNamesJson, String personRole,
|
||||||
|
String dateFrom, String dateTo, String keywordsJson) {
|
||||||
|
String inner = String.format(
|
||||||
|
"{\"personNames\":%s,\"personRole\":\"%s\",\"dateFrom\":%s,\"dateTo\":%s,\"keywords\":%s}",
|
||||||
|
personNamesJson, personRole,
|
||||||
|
dateFrom == null ? "null" : "\"" + dateFrom + "\"",
|
||||||
|
dateTo == null ? "null" : "\"" + dateTo + "\"",
|
||||||
|
keywordsJson
|
||||||
|
);
|
||||||
|
return String.format("{\"model\":\"qwen2.5:7b-instruct-q4_K_M\",\"response\":\"%s\",\"done\":true}",
|
||||||
|
inner.replace("\"", "\\\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test cases ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parse_returnsExtraction_whenOllamaReturnsValidJson() {
|
||||||
|
String body = makeOllamaResponseJson("[\"Walter\"]", "sender", "1914-01-01", "1914-12-31", "[\"Krieg\"]");
|
||||||
|
wireMock.stubFor(post(urlEqualTo("/api/generate"))
|
||||||
|
.willReturn(aResponse()
|
||||||
|
.withStatus(200)
|
||||||
|
.withHeader("Content-Type", "application/json")
|
||||||
|
.withBody(body)));
|
||||||
|
|
||||||
|
OllamaExtraction result = client.parse("Was hat Walter im Krieg geschrieben?");
|
||||||
|
|
||||||
|
assertThat(result.personNames()).containsExactly("Walter");
|
||||||
|
assertThat(result.personRole()).isEqualTo("sender");
|
||||||
|
assertThat(result.keywords()).containsExactly("Krieg");
|
||||||
|
assertThat(result.dateFrom()).isNotNull();
|
||||||
|
assertThat(result.dateTo()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parse_throwsSmartSearchUnavailable_whenOllamaReturns500() {
|
||||||
|
wireMock.stubFor(post(urlEqualTo("/api/generate"))
|
||||||
|
.willReturn(aResponse().withStatus(500)));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> client.parse("some query"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parse_throwsSmartSearchUnavailable_whenOllamaExceedsTimeout() {
|
||||||
|
wireMock.stubFor(post(urlEqualTo("/api/generate"))
|
||||||
|
.willReturn(aResponse()
|
||||||
|
.withStatus(200)
|
||||||
|
.withHeader("Content-Type", "application/json")
|
||||||
|
.withFixedDelay(6000)
|
||||||
|
.withBody("{\"response\":\"{}\",\"done\":true}")));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> client.parse("some query"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parse_throwsSmartSearchUnavailable_whenOllamaReturnsMalformedJson() {
|
||||||
|
wireMock.stubFor(post(urlEqualTo("/api/generate"))
|
||||||
|
.willReturn(aResponse()
|
||||||
|
.withStatus(200)
|
||||||
|
.withHeader("Content-Type", "application/json")
|
||||||
|
.withBody("{\"response\":\"not-json-at-all\",\"done\":true}")));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> client.parse("some query"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user