feat(search): add RestClientNlpClient — POST /parse, GET /health with persons_loaded check
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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 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<String> names = response.personNames() == null ? List.of() : response.personNames().stream()
|
||||
.filter(n -> n != null && n.length() <= MAX_NAME_LENGTH)
|
||||
.toList();
|
||||
List<String> 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<String> personNames,
|
||||
@JsonProperty("personRole") String personRole,
|
||||
@JsonProperty("dateFrom") String dateFrom,
|
||||
@JsonProperty("dateTo") String dateTo,
|
||||
@JsonProperty("keywords") List<String> keywords
|
||||
) {
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record NlpHealthResponse(
|
||||
@JsonProperty("persons_loaded") int personsLoaded
|
||||
) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user