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); + } +}