diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/RestClientNlpClient.java b/backend/src/main/java/org/raddatz/familienarchiv/search/RestClientNlpClient.java new file mode 100644 index 00000000..d058e470 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/RestClientNlpClient.java @@ -0,0 +1,145 @@ +package org.raddatz.familienarchiv.search; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.net.http.HttpClient; +import java.time.Duration; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Set; + +@Service +@Slf4j +public class RestClientNlpClient implements NlpClient, NlpHealthClient { + + 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 final RestClient parseClient; + private final RestClient healthRestClient; + + public RestClientNlpClient(NlpProperties props) { + HttpClient parseHttp = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + JdkClientHttpRequestFactory parseFactory = new JdkClientHttpRequestFactory(parseHttp); + parseFactory.setReadTimeout(Duration.ofSeconds(props.getTimeoutSeconds())); + this.parseClient = RestClient.builder() + .baseUrl(props.getBaseUrl()) + .requestFactory(parseFactory) + .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.healthRestClient = RestClient.builder() + .baseUrl(props.getBaseUrl()) + .requestFactory(healthFactory) + .build(); + } + + @Override + public NlpExtraction parse(String query, String lang) { + try { + NlpParseRequest request = new NlpParseRequest(query, lang); + NlpParseResponse response = parseClient.post() + .uri("/parse") + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(NlpParseResponse.class); + return toExtraction(response, query); + } catch (DomainException e) { + throw e; + } catch (Exception e) { + log.warn("NLP service inference failed: {}", e.getClass().getSimpleName()); + throw DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE, + "NLP service unavailable: " + e.getClass().getSimpleName()); + } + } + + @Override + public boolean isHealthy() { + try { + NlpHealthResponse resp = healthRestClient.get() + .uri("/health") + .retrieve() + .body(NlpHealthResponse.class); + return resp != null && resp.personsLoaded() > 0; + } catch (Exception e) { + return false; + } + } + + private NlpExtraction toExtraction(NlpParseResponse response, String rawQuery) { + if (response == null) { + return fallbackExtraction(rawQuery); + } + List names = response.personNames() == null ? List.of() : response.personNames().stream() + .filter(n -> n != null && n.length() <= MAX_NAME_LENGTH) + .toList(); + List keywords = response.keywords() == null ? List.of() : response.keywords().stream() + .filter(k -> k != null && k.length() <= MAX_KEYWORD_LENGTH) + .toList(); + String role = sanitiseRole(response.personRole()); + LocalDate dateFrom = parseDate(response.dateFrom()); + LocalDate dateTo = parseDate(response.dateTo()); + return new NlpExtraction(names, role, dateFrom, dateTo, keywords, rawQuery); + } + + private NlpExtraction fallbackExtraction(String rawQuery) { + return new NlpExtraction(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 NLP service: {}", role); + return "any"; + } + + private LocalDate parseDate(String raw) { + if (raw == null || raw.isBlank()) return null; + try { + return LocalDate.parse(raw, DateTimeFormatter.ISO_LOCAL_DATE); + } catch (DateTimeParseException ignored) { + } + return null; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record NlpParseRequest(String query, String lang) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record NlpParseResponse( + @JsonProperty("personNames") List personNames, + @JsonProperty("personRole") String personRole, + @JsonProperty("dateFrom") String dateFrom, + @JsonProperty("dateTo") String dateTo, + @JsonProperty("keywords") List keywords + ) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record NlpHealthResponse( + @JsonProperty("persons_loaded") int personsLoaded + ) { + } +}