diff --git a/backend/pom.xml b/backend/pom.xml
index ddf680a6..b01f2362 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -41,6 +41,27 @@
pom
import
+
+
+ org.eclipse.jetty.ee10
+ jetty-ee10-servlet
+ 12.1.8
+
+
+ org.eclipse.jetty.ee10
+ jetty-ee10-servlets
+ 12.1.8
+
+
+ org.eclipse.jetty.ee10
+ jetty-ee10-webapp
+ 12.1.8
+
+
+ org.eclipse.jetty
+ jetty-ee
+ 12.1.8
+
@@ -140,7 +161,7 @@
org.wiremock
- wiremock
+ wiremock-jetty12
3.9.2
test
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaClient.java b/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaClient.java
index c0c8f1da..8517d4df 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaClient.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaClient.java
@@ -1,5 +1,5 @@
package org.raddatz.familienarchiv.search;
public interface OllamaClient {
- NlQueryInterpretation parse(String query);
+ OllamaExtraction parse(String query);
}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaExtraction.java b/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaExtraction.java
new file mode 100644
index 00000000..cc3dce6a
--- /dev/null
+++ b/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaExtraction.java
@@ -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 personNames,
+ String personRole,
+ LocalDate dateFrom,
+ LocalDate dateTo,
+ List keywords,
+ String rawQuery
+) {
+}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/RestClientOllamaClient.java b/backend/src/main/java/org/raddatz/familienarchiv/search/RestClientOllamaClient.java
new file mode 100644
index 00000000..5b86eaf3
--- /dev/null
+++ b/backend/src/main/java/org/raddatz/familienarchiv/search/RestClientOllamaClient.java
@@ -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 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 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 names = raw.personNames() == null ? List.of() : raw.personNames().stream()
+ .filter(n -> n != null && n.length() <= MAX_NAME_LENGTH)
+ .toList();
+ List 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 personNames,
+ @JsonProperty("personRole") String personRole,
+ @JsonProperty("dateFrom") String dateFrom,
+ @JsonProperty("dateTo") String dateTo,
+ @JsonProperty("keywords") List keywords
+ ) {
+ }
+
+ private record OllamaGenerateRequest(
+ String model,
+ String prompt,
+ Object format,
+ boolean stream
+ ) {
+ }
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/RestClientOllamaClientTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/RestClientOllamaClientTest.java
new file mode 100644
index 00000000..058ec095
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/search/RestClientOllamaClientTest.java
@@ -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);
+ }
+}