refactor(search): delete backend NLP search package
Remove entire backend search domain including: - NlSearchController, NlQueryParserService, NlpClient implementations - Rate limiting, properties, DTOs (NlSearchRequest/Response/NlQueryInterpretation) - All domain logic and tests (5 test files deleted) Backend compiles successfully post-deletion. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,26 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record NlQueryInterpretation(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<PersonHint> resolvedPersons,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<PersonHint> ambiguousPersons,
|
||||
LocalDate dateFrom,
|
||||
LocalDate dateTo,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<String> keywords,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<TagHint> resolvedTags,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
String rawQuery,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
boolean keywordsApplied,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
boolean tagsApplied
|
||||
) {
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentSort;
|
||||
import org.raddatz.familienarchiv.document.SearchFilters;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.NameMatches;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||
import org.raddatz.familienarchiv.tag.TagService;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class NlQueryParserService {
|
||||
|
||||
private static final int MIN_QUERY = 3;
|
||||
private static final int MAX_QUERY = 500;
|
||||
private static final int MAX_NAME_LENGTH = 200;
|
||||
private static final int MIN_TAG_TERM = 3;
|
||||
private static final int MAX_RESOLVED_TAGS = 10;
|
||||
|
||||
private final NlpClient nlpClient;
|
||||
private final PersonService personService;
|
||||
private final DocumentService documentService;
|
||||
private final TagService tagService;
|
||||
|
||||
public NlSearchResponse search(String query, String lang, Pageable pageable) {
|
||||
NlpExtraction ext = nlpClient.parse(query, lang);
|
||||
|
||||
List<String> personNames = ext.personNames() != null ? ext.personNames() : List.of();
|
||||
List<String> keywords = ext.keywords() != null ? ext.keywords() : List.of();
|
||||
|
||||
TagResolution tagResolution = resolveTags(keywords);
|
||||
List<TagHint> resolvedTagHints = tagResolution.hints();
|
||||
List<String> resolvedTagNames = tagResolution.tagNames();
|
||||
List<String> remainingKeywords = tagResolution.remaining();
|
||||
|
||||
NameResolution resolution = resolveNames(personNames);
|
||||
|
||||
if (!resolution.ambiguous().isEmpty()) {
|
||||
NlQueryInterpretation interpretation = new NlQueryInterpretation(
|
||||
List.of(), resolution.ambiguous(),
|
||||
ext.dateFrom(), ext.dateTo(),
|
||||
keywords, List.of(), ext.rawQuery(), false, false);
|
||||
return new NlSearchResponse(DocumentSearchResult.of(List.of()), interpretation);
|
||||
}
|
||||
|
||||
List<PersonHint> resolved = resolution.resolved();
|
||||
List<String> noMatchFragments = resolution.noMatchFragments();
|
||||
List<String> extraFragments = resolution.extraFragments();
|
||||
|
||||
boolean hadStructuredMatch = !resolvedTagHints.isEmpty() || !resolved.isEmpty();
|
||||
String text = buildText(remainingKeywords, noMatchFragments, extraFragments, ext.rawQuery(), hadStructuredMatch);
|
||||
|
||||
if (resolved.size() == 1 && isAnyRole(ext.personRole())) {
|
||||
UUID personId = resolved.get(0).id();
|
||||
DocumentSearchResult docs = documentService.searchDocumentsByPersonId(
|
||||
personId, ext.dateFrom(), ext.dateTo(), pageable);
|
||||
NlQueryInterpretation interpretation = new NlQueryInterpretation(
|
||||
resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, resolvedTagHints, ext.rawQuery(), false, false);
|
||||
return new NlSearchResponse(docs, interpretation);
|
||||
}
|
||||
|
||||
UUID sender = buildSender(resolved, ext.personRole());
|
||||
UUID receiver = buildReceiver(resolved, ext.personRole());
|
||||
|
||||
boolean tagsApplied = !resolvedTagHints.isEmpty();
|
||||
TagOperator tagOperator = tagsApplied ? TagOperator.OR : TagOperator.AND;
|
||||
|
||||
SearchFilters filters = new SearchFilters(
|
||||
text.isBlank() ? null : text,
|
||||
ext.dateFrom(), ext.dateTo(),
|
||||
sender, receiver,
|
||||
resolvedTagNames, null,
|
||||
null, tagOperator, false);
|
||||
|
||||
DocumentSearchResult docs = documentService.searchDocuments(filters, DocumentSort.DATE, "desc", pageable);
|
||||
boolean keywordsApplied = !text.isBlank();
|
||||
NlQueryInterpretation interpretation = new NlQueryInterpretation(
|
||||
resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, resolvedTagHints, ext.rawQuery(), keywordsApplied, tagsApplied);
|
||||
return new NlSearchResponse(docs, interpretation);
|
||||
}
|
||||
|
||||
private NameResolution resolveNames(List<String> personNames) {
|
||||
List<PersonHint> resolved = new ArrayList<>();
|
||||
List<PersonHint> ambiguous = new ArrayList<>();
|
||||
List<String> noMatchFragments = new ArrayList<>();
|
||||
List<String> extraFragments = new ArrayList<>();
|
||||
|
||||
int resolvedIndex = 0;
|
||||
for (String name : personNames) {
|
||||
if (name == null || name.length() > MAX_NAME_LENGTH) {
|
||||
log.debug("Skipping name fragment (too long or null): length={}", name == null ? 0 : name.length());
|
||||
continue;
|
||||
}
|
||||
NameMatches matches = personService.resolveByName(name);
|
||||
List<Person> direct = matches.direct();
|
||||
List<Person> partial = matches.partial();
|
||||
|
||||
if (direct.size() == 1) {
|
||||
Person p = direct.get(0);
|
||||
resolvedIndex++;
|
||||
if (resolvedIndex <= 2) {
|
||||
resolved.add(new PersonHint(p.getId(), p.getDisplayName()));
|
||||
} else {
|
||||
extraFragments.add(name);
|
||||
}
|
||||
} else if (direct.size() >= 2) {
|
||||
direct.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName())));
|
||||
} else if (!partial.isEmpty()) {
|
||||
partial.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName())));
|
||||
} else {
|
||||
noMatchFragments.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return new NameResolution(resolved, ambiguous, noMatchFragments, extraFragments);
|
||||
}
|
||||
|
||||
private TagResolution resolveTags(List<String> keywords) {
|
||||
LinkedHashSet<Tag> seen = new LinkedHashSet<>();
|
||||
List<String> remaining = new ArrayList<>();
|
||||
|
||||
for (String kw : keywords) {
|
||||
if (kw == null || kw.length() < MIN_TAG_TERM) {
|
||||
remaining.add(kw);
|
||||
continue;
|
||||
}
|
||||
List<Tag> matches = tagService.findByNameContaining(kw);
|
||||
if (matches.isEmpty()) {
|
||||
remaining.add(kw);
|
||||
} else {
|
||||
seen.addAll(matches);
|
||||
}
|
||||
}
|
||||
|
||||
if (seen.size() > MAX_RESOLVED_TAGS) {
|
||||
log.debug("Keyword matched {} tags; capping at {}", seen.size(), MAX_RESOLVED_TAGS);
|
||||
}
|
||||
List<Tag> capped = seen.size() > MAX_RESOLVED_TAGS
|
||||
? new ArrayList<>(seen).subList(0, MAX_RESOLVED_TAGS)
|
||||
: new ArrayList<>(seen);
|
||||
|
||||
// safe: entities are detached here; mutation is for DTO projection only, no dirty-check fires
|
||||
tagService.resolveEffectiveColors(capped);
|
||||
|
||||
List<TagHint> hints = capped.stream()
|
||||
.map(t -> new TagHint(t.getId(), t.getName(), t.getColor()))
|
||||
.toList();
|
||||
List<String> tagNames = capped.stream().map(Tag::getName).toList();
|
||||
|
||||
return new TagResolution(hints, tagNames, remaining);
|
||||
}
|
||||
|
||||
private String buildText(List<String> keywords, List<String> noMatchFragments,
|
||||
List<String> extraFragments, String rawQuery, boolean hadStructuredMatch) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
parts.addAll(keywords);
|
||||
parts.addAll(noMatchFragments);
|
||||
parts.addAll(extraFragments);
|
||||
String text = String.join(" ", parts).strip();
|
||||
if (text.isBlank() && !hadStructuredMatch && rawQuery != null && !rawQuery.isBlank()) {
|
||||
return rawQuery;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private boolean isAnyRole(String role) {
|
||||
return role == null || "any".equals(role) || (!"sender".equals(role) && !"receiver".equals(role));
|
||||
}
|
||||
|
||||
private UUID buildSender(List<PersonHint> resolved, String role) {
|
||||
if (resolved.size() >= 2) return resolved.get(0).id();
|
||||
if (resolved.size() == 1 && "sender".equals(role)) return resolved.get(0).id();
|
||||
return null;
|
||||
}
|
||||
|
||||
private UUID buildReceiver(List<PersonHint> resolved, String role) {
|
||||
if (resolved.size() >= 2) return resolved.get(1).id();
|
||||
if (resolved.size() == 1 && "receiver".equals(role)) return resolved.get(0).id();
|
||||
return null;
|
||||
}
|
||||
|
||||
private record NameResolution(
|
||||
List<PersonHint> resolved,
|
||||
List<PersonHint> ambiguous,
|
||||
List<String> noMatchFragments,
|
||||
List<String> extraFragments
|
||||
) {}
|
||||
|
||||
private record TagResolution(
|
||||
List<TagHint> hints,
|
||||
List<String> tagNames,
|
||||
List<String> remaining
|
||||
) {}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/search/nl")
|
||||
@RequiredArgsConstructor
|
||||
public class NlSearchController {
|
||||
|
||||
private final NlQueryParserService nlQueryParserService;
|
||||
private final NlSearchRateLimiter rateLimiter;
|
||||
|
||||
@PostMapping
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public NlSearchResponse search(@Valid @RequestBody NlSearchRequest request,
|
||||
Pageable pageable,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
rateLimiter.checkAndConsume(principal.getUsername());
|
||||
return nlQueryParserService.search(request.query(), request.lang(), pageable);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties("app.nl-search.rate-limit")
|
||||
@Data
|
||||
public class NlSearchRateLimitProperties {
|
||||
private int maxRequestsPerMinute = 20;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.github.benmanes.caffeine.cache.LoadingCache;
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service
|
||||
public class NlSearchRateLimiter {
|
||||
|
||||
private final LoadingCache<String, Bucket> byUser;
|
||||
private final int maxRequestsPerMinute;
|
||||
|
||||
public NlSearchRateLimiter(NlSearchRateLimitProperties props) {
|
||||
this.maxRequestsPerMinute = props.getMaxRequestsPerMinute();
|
||||
this.byUser = Caffeine.newBuilder()
|
||||
.expireAfterAccess(1, TimeUnit.MINUTES)
|
||||
.build(key -> newBucket(maxRequestsPerMinute));
|
||||
}
|
||||
|
||||
public void checkAndConsume(String userKey) {
|
||||
if (!byUser.get(userKey).tryConsume(1)) {
|
||||
throw DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_RATE_LIMITED,
|
||||
"NL search rate limit exceeded for user: " + userKey, 60L);
|
||||
}
|
||||
}
|
||||
|
||||
void resetForTest() {
|
||||
byUser.invalidateAll();
|
||||
}
|
||||
|
||||
private static Bucket newBucket(int limit) {
|
||||
return Bucket.builder()
|
||||
.addLimit(Bandwidth.builder()
|
||||
.capacity(limit)
|
||||
.refillGreedy(limit, Duration.ofMinutes(1))
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record NlSearchRequest(
|
||||
@NotBlank
|
||||
@Size(min = 3, max = 500)
|
||||
String query,
|
||||
@NotBlank
|
||||
@Pattern(regexp = "de|en|es")
|
||||
String lang
|
||||
) {
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||
|
||||
public record NlSearchResponse(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
DocumentSearchResult result,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
NlQueryInterpretation interpretation
|
||||
) {
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
public interface NlpClient {
|
||||
NlpExtraction parse(String query, String lang);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
record NlpExtraction(
|
||||
List<String> personNames,
|
||||
String personRole,
|
||||
LocalDate dateFrom,
|
||||
LocalDate dateTo,
|
||||
List<String> keywords,
|
||||
String rawQuery
|
||||
) {
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
public interface NlpHealthClient {
|
||||
boolean isHealthy();
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
@ConfigurationProperties("app.nlp")
|
||||
@Data
|
||||
@Validated
|
||||
public class NlpProperties {
|
||||
@NotBlank
|
||||
private String baseUrl;
|
||||
private int timeoutSeconds = 5;
|
||||
private int healthCheckTimeoutSeconds = 2;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record PersonHint(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
String displayName
|
||||
) {
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record TagHint(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
String name,
|
||||
String color
|
||||
) {
|
||||
}
|
||||
@@ -1,711 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentSort;
|
||||
import org.raddatz.familienarchiv.document.SearchFilters;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.NameMatches;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||
import org.raddatz.familienarchiv.tag.TagService;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class NlQueryParserServiceTest {
|
||||
|
||||
@Mock NlpClient nlpClient;
|
||||
@Mock PersonService personService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock TagService tagService;
|
||||
|
||||
NlQueryParserService service;
|
||||
|
||||
static final Pageable PAGE = PageRequest.of(0, 20);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
service = new NlQueryParserService(nlpClient, personService, documentService, tagService);
|
||||
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
when(documentService.searchDocumentsByPersonId(any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
when(tagService.findByNameContaining(anyString())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
// --- Factory helpers ---
|
||||
|
||||
private NlpExtraction extraction(List<String> names, String role, LocalDate from, LocalDate to,
|
||||
List<String> keywords) {
|
||||
String raw = names.isEmpty() ? "test query" : String.join(" ", names);
|
||||
return new NlpExtraction(names, role, from, to, keywords, raw);
|
||||
}
|
||||
|
||||
private Person person(UUID id, String firstName, String lastName) {
|
||||
return Person.builder().id(id).firstName(firstName).lastName(lastName).build();
|
||||
}
|
||||
|
||||
private NameMatches makeNameMatches() {
|
||||
return new NameMatches(List.of(), List.of());
|
||||
}
|
||||
|
||||
private NameMatches makeNameMatches(List<Person> direct) {
|
||||
return new NameMatches(direct, List.of());
|
||||
}
|
||||
|
||||
private NameMatches makeNameMatches(List<Person> direct, List<Person> partial) {
|
||||
return new NameMatches(direct, partial);
|
||||
}
|
||||
|
||||
private static final UUID P1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
private static final UUID P2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||
private static final UUID P3 = UUID.fromString("00000000-0000-0000-0000-000000000003");
|
||||
|
||||
// --- 1. Single resolved name + personRole=sender ---
|
||||
|
||||
@Test
|
||||
void search_resolvesSingleName_asSender() {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
|
||||
NlSearchResponse resp = service.search("Was hat Walter geschrieben?", "de", PAGE);
|
||||
|
||||
verify(nlpClient).parse(eq("Was hat Walter geschrieben?"), eq("de"));
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
|
||||
assertThat(cap.getValue().sender()).isEqualTo(P1);
|
||||
assertThat(cap.getValue().receiver()).isNull();
|
||||
assertThat(resp.interpretation().resolvedPersons()).hasSize(1);
|
||||
assertThat(resp.interpretation().resolvedPersons().get(0).id()).isEqualTo(P1);
|
||||
assertThat(resp.interpretation().ambiguousPersons()).isEmpty();
|
||||
}
|
||||
|
||||
// --- 2. Multi-match name → ambiguous, search NOT executed ---
|
||||
|
||||
@Test
|
||||
void search_multiMatchName_populatesAmbiguous_andSkipsSearch() {
|
||||
Person a = person(UUID.randomUUID(), "Walter", "Braun");
|
||||
Person b = person(UUID.randomUUID(), "Walter", "Schmidt");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(a, b)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter", "de", PAGE);
|
||||
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
verify(documentService, never()).searchDocumentsByPersonId(any(), any(), any(), any());
|
||||
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
|
||||
assertThat(resp.interpretation().resolvedPersons()).isEmpty();
|
||||
}
|
||||
|
||||
// --- 3. Multi-match + personRole=any → still ambiguous, search NOT executed ---
|
||||
|
||||
@Test
|
||||
void search_multiMatchName_withPersonRoleAny_stillSkipsSearch() {
|
||||
Person a = person(UUID.randomUUID(), "Emma", "Braun");
|
||||
Person b = person(UUID.randomUUID(), "Emma", "Raddatz");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of("Emma"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(a, b)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe an Emma", "de", PAGE);
|
||||
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
verify(documentService, never()).searchDocumentsByPersonId(any(), any(), any(), any());
|
||||
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
|
||||
}
|
||||
|
||||
// --- 4. No-match name → folded into text ---
|
||||
|
||||
@Test
|
||||
void search_noMatchName_isFoldedIntoText() {
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of("Karl"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Karl")).thenReturn(makeNameMatches());
|
||||
|
||||
service.search("Briefe von Karl", "de", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).contains("Karl");
|
||||
assertThat(cap.getValue().sender()).isNull();
|
||||
assertThat(cap.getValue().receiver()).isNull();
|
||||
}
|
||||
|
||||
// --- 5. personRole=any + 1 resolved → searchDocumentsByPersonId called ---
|
||||
|
||||
@Test
|
||||
void search_personRoleAny_singleMatch_callsSearchDocumentsByPersonId() {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter", "de", PAGE);
|
||||
|
||||
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
assertThat(resp.interpretation().keywordsApplied()).isFalse();
|
||||
}
|
||||
|
||||
// --- 6. 2 names both resolve → sender=person1, receiver=person2 ---
|
||||
|
||||
@Test
|
||||
void search_twoNamesResolve_assignsSenderAndReceiver() {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
Person emma = person(P2, "Emma", "Raddatz");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of("Walter", "Emma"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter an Emma", "de", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
|
||||
assertThat(cap.getValue().sender()).isEqualTo(P1);
|
||||
assertThat(cap.getValue().receiver()).isEqualTo(P2);
|
||||
assertThat(resp.interpretation().resolvedPersons().get(0).id()).isEqualTo(P1);
|
||||
assertThat(resp.interpretation().resolvedPersons().get(1).id()).isEqualTo(P2);
|
||||
}
|
||||
|
||||
// --- 7. 2 names, first resolves, second ambiguous → search NOT executed ---
|
||||
|
||||
@Test
|
||||
void search_twoNames_secondAmbiguous_skipsSearch() {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
Person emma1 = person(P2, "Emma", "Braun");
|
||||
Person emma2 = person(P3, "Emma", "Schmidt");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of("Walter", "Emma"), "sender", null, null, List.of()));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma1, emma2)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter an Emma", "de", PAGE);
|
||||
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
|
||||
}
|
||||
|
||||
// --- 8. 2 names, first no match → folded into text, second used as single person ---
|
||||
|
||||
@Test
|
||||
void search_twoNames_firstNoMatch_secondResolved_foldFirstIntoText() {
|
||||
Person emma = person(P2, "Emma", "Raddatz");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of("Karl", "Emma"), "sender", null, null, List.of()));
|
||||
when(personService.resolveByName("Karl")).thenReturn(makeNameMatches());
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||
|
||||
service.search("Briefe von Karl an Emma", "de", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).contains("Karl");
|
||||
assertThat(cap.getValue().sender()).isEqualTo(P2);
|
||||
}
|
||||
|
||||
// --- 9. 3+ names all resolve → first two as sender/receiver, third folded into text ---
|
||||
|
||||
@Test
|
||||
void search_threeNamesResolve_extraFoldedIntoText() {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
Person emma = person(P2, "Emma", "Raddatz");
|
||||
Person heinrich = person(P3, "Heinrich", "Braun");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of("Walter", "Emma", "Heinrich"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||
when(personService.resolveByName("Heinrich")).thenReturn(makeNameMatches(List.of(heinrich)));
|
||||
|
||||
service.search("Briefe von Walter an Emma über Heinrich", "de", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().sender()).isEqualTo(P1);
|
||||
assertThat(cap.getValue().receiver()).isEqualTo(P2);
|
||||
assertThat(cap.getValue().text()).contains("Heinrich");
|
||||
}
|
||||
|
||||
// --- 10. Keywords space-joined into text ---
|
||||
|
||||
@Test
|
||||
void search_keywords_areJoinedIntoText() {
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg", "Walter")));
|
||||
|
||||
service.search("Dokumente über den Krieg Walter", "de", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).isEqualTo("Krieg Walter");
|
||||
}
|
||||
|
||||
// --- 11. Date range passed through ---
|
||||
|
||||
@Test
|
||||
void search_dateRange_passedIntoSearchFilters() {
|
||||
LocalDate from = LocalDate.of(1914, 1, 1);
|
||||
LocalDate to = LocalDate.of(1914, 12, 31);
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", from, to, List.of()));
|
||||
|
||||
service.search("Briefe aus dem Jahr 1914", "de", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().from()).isEqualTo(from);
|
||||
assertThat(cap.getValue().to()).isEqualTo(to);
|
||||
}
|
||||
|
||||
// --- 12. Null dates → null in SearchFilters (not an error) ---
|
||||
|
||||
@Test
|
||||
void search_nullDates_passedAsNullIntoFilters() {
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
|
||||
service.search("Hochzeitsbriefe", "de", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().from()).isNull();
|
||||
assertThat(cap.getValue().to()).isNull();
|
||||
}
|
||||
|
||||
// --- 13. NLP service returns empty names/keywords → raw query used as keyword fallback ---
|
||||
|
||||
@Test
|
||||
void search_nlpReturnsEmpty_usesRawQueryAsTextFallback() {
|
||||
String raw = "Briefe aus dem Krieg";
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(new NlpExtraction(List.of(), "any", null, null, List.of(), raw));
|
||||
|
||||
service.search(raw, "de", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).isEqualTo(raw);
|
||||
}
|
||||
|
||||
// --- 14. Null personNames/keywords → no NPE ---
|
||||
|
||||
@Test
|
||||
void search_nullPersonNamesAndKeywords_handledWithoutNpe() {
|
||||
NlpExtraction ext = new NlpExtraction(null, "any", null, null, null, "test query");
|
||||
when(nlpClient.parse(anyString(), anyString())).thenReturn(ext);
|
||||
|
||||
NlSearchResponse resp = service.search("test query", "de", PAGE);
|
||||
|
||||
assertThat(resp).isNotNull();
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// --- 15. Unrecognized personRole → defaults to any-like behavior (no crash) ---
|
||||
|
||||
@Test
|
||||
void search_unrecognizedPersonRole_treatedLikeAny_withSingleResolvedPerson() {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(new NlpExtraction(List.of("Walter"), "unknown_role", null, null, List.of(), "query"));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter", "de", PAGE);
|
||||
|
||||
assertThat(resp).isNotNull();
|
||||
}
|
||||
|
||||
// --- 16. NLP service throws SMART_SEARCH_UNAVAILABLE → propagates to caller ---
|
||||
|
||||
@Test
|
||||
void search_nlpThrowsUnavailable_propagates() {
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenThrow(DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_UNAVAILABLE, "offline"));
|
||||
|
||||
assertThatThrownBy(() -> service.search("Was hat Walter geschrieben?", "de", PAGE))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
|
||||
}
|
||||
|
||||
// --- 17. LLM-extracted name > 200 chars → skipped, PersonService never called ---
|
||||
|
||||
@Test
|
||||
void search_nameLongerThan200Chars_isSkippedBeforePersonServiceCall() {
|
||||
String longName = "A".repeat(201);
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of(longName), "sender", null, null, List.of()));
|
||||
|
||||
service.search("Briefe von sehr langem Namen", "de", PAGE);
|
||||
|
||||
verify(personService, never()).resolveByName(anyString());
|
||||
}
|
||||
|
||||
// --- 18. Cap: 10 direct matches → all shown as ambiguous ---
|
||||
|
||||
@Test
|
||||
void search_tenDirectMatches_allShownAsAmbiguous() {
|
||||
List<Person> ten = new ArrayList<>();
|
||||
for (int i = 0; i < 10; i++) {
|
||||
ten.add(person(UUID.randomUUID(), "Walter", "Person" + i));
|
||||
}
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(ten));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter", "de", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().ambiguousPersons()).hasSize(10);
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// --- 19. SearchFilters defaults: tagOperator=AND, status=null, undated=false, tags=empty ---
|
||||
|
||||
@Test
|
||||
void search_searchFiltersDefaults_areCorrect() {
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg")));
|
||||
|
||||
service.search("Dokumente über den Krieg", "de", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
|
||||
SearchFilters f = cap.getValue();
|
||||
assertThat(f.tagOperator()).isEqualTo(TagOperator.AND);
|
||||
assertThat(f.status()).isNull();
|
||||
assertThat(f.undated()).isFalse();
|
||||
assertThat(f.tags()).isEmpty();
|
||||
assertThat(f.tagQ()).isNull();
|
||||
}
|
||||
|
||||
// --- 20. personRole=receiver + 1 resolved → receiver UUID set ---
|
||||
|
||||
@Test
|
||||
void search_personRoleReceiver_singleMatch_setsReceiver() {
|
||||
Person emma = person(P2, "Emma", "Raddatz");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of("Emma"), "receiver", null, null, List.of()));
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||
|
||||
service.search("Briefe an Emma", "de", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().receiver()).isEqualTo(P2);
|
||||
assertThat(cap.getValue().sender()).isNull();
|
||||
}
|
||||
|
||||
// --- 21. keywordsApplied=true when text is non-blank ---
|
||||
|
||||
@Test
|
||||
void search_keywordsApplied_trueWhenTextNonBlank() {
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost")));
|
||||
|
||||
NlSearchResponse resp = service.search("Feldpost aus dem Krieg", "de", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().keywordsApplied()).isTrue();
|
||||
}
|
||||
|
||||
// --- 22. Partial-only, one candidate → ambiguous (1-item picker), search skipped ---
|
||||
|
||||
@Test
|
||||
void search_partialOnly_oneCandidate_populatesAmbiguous() {
|
||||
Person cramer = person(P1, "Clara", "Cramer");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Clara Cram")).thenReturn(makeNameMatches(List.of(), List.of(cramer)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Clara Cram", "de", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().ambiguousPersons()).hasSize(1);
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// --- 23. Partial-only, two candidates → ambiguous (multi-item picker) ---
|
||||
|
||||
@Test
|
||||
void search_partialOnly_twoCandidates_populatesAmbiguous() {
|
||||
Person cramer = person(P1, "Clara", "Cramer");
|
||||
Person crammond = person(P2, "Clara", "Crammond");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Clara Cram"))
|
||||
.thenReturn(makeNameMatches(List.of(), List.of(cramer, crammond)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Clara Cram", "de", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
|
||||
}
|
||||
|
||||
// --- 24. Exactly one direct match → search executes, no picker ---
|
||||
|
||||
@Test
|
||||
void search_oneDirect_executesSearch() {
|
||||
Person clara = person(P1, "Clara", "Cram");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Clara Cram")).thenReturn(makeNameMatches(List.of(clara)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Clara Cram", "de", PAGE);
|
||||
|
||||
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
|
||||
assertThat(resp.interpretation().ambiguousPersons()).isEmpty();
|
||||
}
|
||||
|
||||
// --- Tag resolution helpers ---
|
||||
|
||||
private Tag tag(UUID id, String name) {
|
||||
return Tag.builder().id(id).name(name).build();
|
||||
}
|
||||
|
||||
private Tag tag(UUID id, String name, String color) {
|
||||
return Tag.builder().id(id).name(name).color(color).build();
|
||||
}
|
||||
|
||||
private TagHint tagHint(UUID id, String name, String color) {
|
||||
return new TagHint(id, name, color);
|
||||
}
|
||||
|
||||
private static final UUID T1 = UUID.fromString("00000000-0000-0000-0001-000000000001");
|
||||
|
||||
// --- 25. Single keyword resolves to one tag → tag filter applied ---
|
||||
|
||||
@Test
|
||||
void search_singleKeywordResolvesToTag_appliesTagFilter() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe über Hochzeit", "de", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().tags()).containsExactly("Hochzeit");
|
||||
assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR);
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
|
||||
assertThat(resp.interpretation().resolvedTags().get(0).name()).isEqualTo("Hochzeit");
|
||||
assertThat(resp.interpretation().tagsApplied()).isTrue();
|
||||
assertThat(cap.getValue().text()).isNull();
|
||||
}
|
||||
|
||||
private static final UUID T2 = UUID.fromString("00000000-0000-0000-0001-000000000002");
|
||||
|
||||
// --- 26. Keyword matches multiple tags → all in resolvedTags, OR-union ---
|
||||
|
||||
@Test
|
||||
void search_keywordMatchesMultipleTags_allIncluded() {
|
||||
Tag hochzeit1 = tag(T1, "Hochzeit Raddatz");
|
||||
Tag hochzeit2 = tag(T2, "Hochzeit Braun");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit1, hochzeit2));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe über Hochzeit", "de", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().tags()).containsExactlyInAnyOrder("Hochzeit Raddatz", "Hochzeit Braun");
|
||||
assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR);
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(2);
|
||||
}
|
||||
|
||||
// --- 27. Keyword no tag match → stays as FTS text, resolvedTags empty ---
|
||||
|
||||
@Test
|
||||
void search_keywordNoTagMatch_staysAsFtsText() {
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost")));
|
||||
|
||||
NlSearchResponse resp = service.search("Feldpost Briefe", "de", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).contains("Feldpost");
|
||||
assertThat(cap.getValue().tags()).isEmpty();
|
||||
assertThat(resp.interpretation().resolvedTags()).isEmpty();
|
||||
assertThat(resp.interpretation().tagsApplied()).isFalse();
|
||||
}
|
||||
|
||||
// --- 28. Mixed: one keyword resolves, one doesn't → tag filter + FTS text ---
|
||||
|
||||
@Test
|
||||
void search_mixedKeywords_oneResolves_oneStaysAsText() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit", "Feldpost")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit und Feldpost", "de", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().tags()).containsExactly("Hochzeit");
|
||||
assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR);
|
||||
assertThat(cap.getValue().text()).contains("Feldpost");
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
|
||||
assertThat(resp.interpretation().tagsApplied()).isTrue();
|
||||
}
|
||||
|
||||
// --- 29. personRole=any + 1 person + resolvable keyword → personId search, tagsApplied=false ---
|
||||
|
||||
@Test
|
||||
void search_personRoleAny_singlePerson_resolvableKeyword_tagsAppliedFalse() {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "any", null, null, List.of("Hochzeit")));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter über Hochzeit", "de", PAGE);
|
||||
|
||||
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
assertThat(resp.interpretation().tagsApplied()).isFalse();
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
|
||||
assertThat(resp.interpretation().resolvedTags().get(0).name()).isEqualTo("Hochzeit");
|
||||
}
|
||||
|
||||
// --- 30. Cap: keyword matches > 10 tags → capped at 10 ---
|
||||
|
||||
@Test
|
||||
void search_keywordMatchesMoreThanMaxTags_cappedAtTen() {
|
||||
List<Tag> eleven = new ArrayList<>();
|
||||
for (int i = 0; i < 11; i++) {
|
||||
eleven.add(tag(UUID.randomUUID(), "Thema " + i));
|
||||
}
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Thema")));
|
||||
when(tagService.findByNameContaining("Thema")).thenReturn(eleven);
|
||||
|
||||
NlSearchResponse resp = service.search("Dokumente zum Thema", "de", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(10);
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().tags()).hasSize(10);
|
||||
}
|
||||
|
||||
// --- 31. Short keyword (< 3 chars) → skipped, not passed to TagService ---
|
||||
|
||||
@Test
|
||||
void search_shortKeyword_skippedByTagResolution() {
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("ab", "Krieg")));
|
||||
|
||||
service.search("ab Krieg", "de", PAGE);
|
||||
|
||||
verify(tagService, never()).findByNameContaining("ab");
|
||||
verify(tagService).findByNameContaining("Krieg");
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).contains("ab");
|
||||
}
|
||||
|
||||
// --- 32. Dedup: same tag matched by two keywords → appears once ---
|
||||
|
||||
@Test
|
||||
void search_sameTagMatchedByTwoKeywords_deduplicatedToOne() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit", "hoch")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
when(tagService.findByNameContaining("hoch")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit hoch", "de", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().tags()).hasSize(1);
|
||||
}
|
||||
|
||||
// --- 33. All keywords resolve → rawQuery fallback suppressed, text=null ---
|
||||
|
||||
@Test
|
||||
void search_allKeywordsResolveToTags_rawQueryFallbackSuppressed() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(new NlpExtraction(List.of(), "any", null, null, List.of("Hochzeit"), "raw query text"));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit", "de", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).isNull();
|
||||
assertThat(cap.getValue().tags()).containsExactly("Hochzeit");
|
||||
}
|
||||
|
||||
// --- 34. Flag independence: keywordsApplied=false AND tagsApplied=true ---
|
||||
|
||||
@Test
|
||||
void search_allKeywordsResolveToTags_keywordsAppliedFalse_tagsAppliedTrue() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit Briefe", "de", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().keywordsApplied()).isFalse();
|
||||
assertThat(resp.interpretation().tagsApplied()).isTrue();
|
||||
}
|
||||
|
||||
// --- 35. Color carried through from resolveEffectiveColors ---
|
||||
|
||||
@Test
|
||||
void search_tagHint_carriesColorSetByResolveEffectiveColors() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
doAnswer(invocation -> {
|
||||
Collection<Tag> tags = invocation.getArgument(0);
|
||||
tags.forEach(t -> t.setColor("sage"));
|
||||
return null;
|
||||
}).when(tagService).resolveEffectiveColors(any());
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit", "de", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().resolvedTags().get(0).color()).isEqualTo("sage");
|
||||
}
|
||||
|
||||
// --- 36. Color stays null when resolveEffectiveColors leaves it unset ---
|
||||
|
||||
@Test
|
||||
void search_tagHint_colorIsNull_whenNoColorResolved() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(nlpClient.parse(anyString(), anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit", "de", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().resolvedTags().get(0).color()).isNull();
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(NlSearchController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class,
|
||||
NlSearchRateLimiter.class, NlSearchRateLimitProperties.class})
|
||||
class NlSearchControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@MockitoBean NlQueryParserService nlQueryParserService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
@Autowired NlSearchRateLimiter rateLimiter;
|
||||
|
||||
@BeforeEach
|
||||
void resetRateLimiter() {
|
||||
rateLimiter.resetForTest();
|
||||
}
|
||||
|
||||
private NlSearchResponse makeResponse() {
|
||||
PersonHint hint = new PersonHint(UUID.randomUUID(), "Walter Raddatz");
|
||||
NlQueryInterpretation interp = new NlQueryInterpretation(
|
||||
List.of(hint), List.of(), null, null,
|
||||
List.of("Krieg"), List.of(), "Briefe von Walter im Krieg", true, false);
|
||||
return new NlSearchResponse(DocumentSearchResult.of(List.of()), interp);
|
||||
}
|
||||
|
||||
// --- 1. Happy path ---
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||
void search_returns200_withNlSearchResponse() throws Exception {
|
||||
when(nlQueryParserService.search(anyString(), anyString(), any())).thenReturn(makeResponse());
|
||||
|
||||
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"Briefe von Walter im Krieg\",\"lang\":\"de\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.interpretation.rawQuery").value("Briefe von Walter im Krieg"))
|
||||
.andExpect(jsonPath("$.interpretation.resolvedPersons[0].displayName").value("Walter Raddatz"))
|
||||
.andExpect(jsonPath("$.interpretation.keywordsApplied").value(true));
|
||||
}
|
||||
|
||||
// --- 2. ambiguousPersons in response shape ---
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||
void search_returns200_withAmbiguousPersons() throws Exception {
|
||||
PersonHint a = new PersonHint(UUID.randomUUID(), "Walter Braun");
|
||||
PersonHint b = new PersonHint(UUID.randomUUID(), "Walter Schmidt");
|
||||
NlQueryInterpretation interp = new NlQueryInterpretation(
|
||||
List.of(), List.of(a, b), null, null,
|
||||
List.of(), List.of(), "Briefe von Walter", false, false);
|
||||
NlSearchResponse resp = new NlSearchResponse(DocumentSearchResult.of(List.of()), interp);
|
||||
when(nlQueryParserService.search(anyString(), anyString(), any())).thenReturn(resp);
|
||||
|
||||
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"Briefe von Walter\",\"lang\":\"de\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.interpretation.ambiguousPersons").isArray())
|
||||
.andExpect(jsonPath("$.interpretation.ambiguousPersons[0].displayName").value("Walter Braun"))
|
||||
.andExpect(jsonPath("$.interpretation.ambiguousPersons[1].id").isNotEmpty());
|
||||
}
|
||||
|
||||
// --- 3. Unauthenticated → 401 ---
|
||||
|
||||
@Test
|
||||
void search_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"Briefe von Walter\",\"lang\":\"de\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
// --- 4. Query < 3 chars → 400 ---
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||
void search_returns400_whenQueryTooShort() throws Exception {
|
||||
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"ab\",\"lang\":\"de\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// --- 5. Query > 500 chars → 400 ---
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||
void search_returns400_whenQueryTooLong() throws Exception {
|
||||
String longQuery = "a".repeat(501);
|
||||
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"" + longQuery + "\",\"lang\":\"de\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// --- 6. NLP service unavailable → 503 ---
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||
void search_returns503_whenNlpServiceUnavailable() throws Exception {
|
||||
when(nlQueryParserService.search(anyString(), anyString(), any()))
|
||||
.thenThrow(DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE, "NLP service offline"));
|
||||
|
||||
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"Briefe von Walter\",\"lang\":\"de\"}"))
|
||||
.andExpect(status().isServiceUnavailable())
|
||||
.andExpect(jsonPath("$.code").value("SMART_SEARCH_UNAVAILABLE"));
|
||||
}
|
||||
|
||||
// --- 7. 21st request in 1 minute → 429 (rate limit = 20/min default) ---
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||
void search_returns429_on21stRequestWithinRateLimit() throws Exception {
|
||||
when(nlQueryParserService.search(anyString(), anyString(), any())).thenReturn(makeResponse());
|
||||
|
||||
for (int i = 0; i < 20; i++) {
|
||||
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"Briefe von Walter\",\"lang\":\"de\"}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"Briefe von Walter\",\"lang\":\"de\"}"))
|
||||
.andExpect(status().isTooManyRequests())
|
||||
.andExpect(jsonPath("$.code").value("SMART_SEARCH_RATE_LIMITED"));
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
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 org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class NlSearchRateLimiterTest {
|
||||
|
||||
private NlSearchRateLimiter rateLimiter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
NlSearchRateLimitProperties props = new NlSearchRateLimitProperties();
|
||||
props.setMaxRequestsPerMinute(5);
|
||||
rateLimiter = new NlSearchRateLimiter(props);
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndConsume_allowsRequestsWithinLimit() {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
assertThatCode(() -> rateLimiter.checkAndConsume("user@example.com"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndConsume_throwsRateLimited_onSixthRequest() {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
rateLimiter.checkAndConsume("user@example.com");
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> rateLimiter.checkAndConsume("user@example.com"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.SMART_SEARCH_RATE_LIMITED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndConsume_limitsAreIndependentPerUser() {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
rateLimiter.checkAndConsume("alice@example.com");
|
||||
}
|
||||
assertThatCode(() -> rateLimiter.checkAndConsume("bob@example.com"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetForTest_clearsAllBuckets() {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
rateLimiter.checkAndConsume("user@example.com");
|
||||
}
|
||||
|
||||
rateLimiter.resetForTest();
|
||||
|
||||
assertThatCode(() -> rateLimiter.checkAndConsume("user@example.com"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class NlSearchTagResolutionIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private TagRepository tagRepository;
|
||||
|
||||
@Test
|
||||
void findDescendantIdsByName_parentName_includesChildId() {
|
||||
Tag parent = tagRepository.save(Tag.builder().name("Krieg").build());
|
||||
Tag child = tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build());
|
||||
|
||||
List<UUID> ids = tagRepository.findDescendantIdsByName("Krieg");
|
||||
|
||||
assertThat(ids).containsExactlyInAnyOrder(parent.getId(), child.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDescendantIdsByName_childName_returnsOnlyChild() {
|
||||
Tag parent = tagRepository.save(Tag.builder().name("Krieg").build());
|
||||
Tag child = tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build());
|
||||
|
||||
List<UUID> ids = tagRepository.findDescendantIdsByName("Weltkrieg");
|
||||
|
||||
assertThat(ids).containsExactly(child.getId());
|
||||
assertThat(ids).doesNotContain(parent.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByNameContainingIgnoreCase_parentSubstring_matchesParentOnly() {
|
||||
Tag parent = tagRepository.save(Tag.builder().name("Krieg").build());
|
||||
tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build());
|
||||
|
||||
List<Tag> found = tagRepository.findByNameContainingIgnoreCase("Krieg");
|
||||
|
||||
assertThat(found).extracting(Tag::getName).containsExactlyInAnyOrder("Krieg", "Weltkrieg");
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class NlpPropertiesTest {
|
||||
|
||||
@EnableConfigurationProperties(NlpProperties.class)
|
||||
static class TestConfig {}
|
||||
|
||||
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||
.withUserConfiguration(TestConfig.class);
|
||||
|
||||
@Test
|
||||
void failsWhenBaseUrlMissing() {
|
||||
runner.run(context -> assertThat(context).hasFailed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindsBaseUrlAndDefaults() {
|
||||
runner.withPropertyValues("app.nlp.base-url=http://nlp:8001")
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
NlpProperties props = context.getBean(NlpProperties.class);
|
||||
assertThat(props.getBaseUrl()).isEqualTo("http://nlp:8001");
|
||||
assertThat(props.getTimeoutSeconds()).isEqualTo(5);
|
||||
assertThat(props.getHealthCheckTimeoutSeconds()).isEqualTo(2);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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 RestClientNlpClientTest {
|
||||
|
||||
private WireMockServer wireMock;
|
||||
private RestClientNlpClient client;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
wireMock = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
|
||||
wireMock.start();
|
||||
|
||||
NlpProperties props = new NlpProperties();
|
||||
props.setBaseUrl("http://localhost:" + wireMock.port());
|
||||
props.setTimeoutSeconds(5);
|
||||
props.setHealthCheckTimeoutSeconds(2);
|
||||
|
||||
client = new RestClientNlpClient(props);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
wireMock.stop();
|
||||
}
|
||||
|
||||
private String makeParseResponseJson(String personNamesJson, String personRole,
|
||||
String dateFrom, String dateTo, String keywordsJson,
|
||||
String rawQuery) {
|
||||
return String.format(
|
||||
"{\"personNames\":%s,\"personRole\":\"%s\",\"dateFrom\":%s,\"dateTo\":%s,\"keywords\":%s,\"rawQuery\":\"%s\"}",
|
||||
personNamesJson, personRole,
|
||||
dateFrom == null ? "null" : "\"" + dateFrom + "\"",
|
||||
dateTo == null ? "null" : "\"" + dateTo + "\"",
|
||||
keywordsJson, rawQuery
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_returnsExtraction_whenNlpServiceReturnsValidJson() {
|
||||
String body = makeParseResponseJson("[\"Walter\"]", "sender", "1914-01-01", "1914-12-31",
|
||||
"[\"Krieg\"]", "Briefe von Walter im Krieg");
|
||||
wireMock.stubFor(post(urlEqualTo("/parse"))
|
||||
.willReturn(aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader("Content-Type", "application/json")
|
||||
.withBody(body)));
|
||||
|
||||
NlpExtraction result = client.parse("Briefe von Walter im Krieg", "de");
|
||||
|
||||
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_whenNlpServiceReturns500() {
|
||||
wireMock.stubFor(post(urlEqualTo("/parse"))
|
||||
.willReturn(aResponse().withStatus(500)));
|
||||
|
||||
assertThatThrownBy(() -> client.parse("some query", "de"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_throwsSmartSearchUnavailable_whenNlpServiceExceedsTimeout() {
|
||||
wireMock.stubFor(post(urlEqualTo("/parse"))
|
||||
.willReturn(aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader("Content-Type", "application/json")
|
||||
.withFixedDelay(6000)
|
||||
.withBody("{\"personNames\":[],\"personRole\":\"any\",\"dateFrom\":null,\"dateTo\":null,\"keywords\":[],\"rawQuery\":\"q\"}")));
|
||||
|
||||
assertThatThrownBy(() -> client.parse("some query", "de"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isHealthy_returnsTrue_whenPersonsLoadedIsPositive() {
|
||||
wireMock.stubFor(get(urlEqualTo("/health"))
|
||||
.willReturn(aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader("Content-Type", "application/json")
|
||||
.withBody("{\"status\":\"ok\",\"persons_loaded\":42}")));
|
||||
|
||||
assertThat(client.isHealthy()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isHealthy_returnsFalse_whenPersonsLoadedIsZero() {
|
||||
wireMock.stubFor(get(urlEqualTo("/health"))
|
||||
.willReturn(aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader("Content-Type", "application/json")
|
||||
.withBody("{\"status\":\"ok\",\"persons_loaded\":0}")));
|
||||
|
||||
assertThat(client.isHealthy()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isHealthy_returnsFalse_whenNlpServiceIsDown() {
|
||||
wireMock.stubFor(get(urlEqualTo("/health"))
|
||||
.willReturn(aResponse().withStatus(503)));
|
||||
|
||||
assertThat(client.isHealthy()).isFalse();
|
||||
}
|
||||
}
|
||||
768
docs/superpowers/plans/2026-06-07-remove-nlp-search.md
Normal file
768
docs/superpowers/plans/2026-06-07-remove-nlp-search.md
Normal file
@@ -0,0 +1,768 @@
|
||||
# Remove NLP/Smart Search Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remove the NLP/smart-search feature entirely from the codebase — backend search package, frontend components, i18n keys, infrastructure config, and the nlp-service microservice.
|
||||
|
||||
**Architecture:** Pure deletion + targeted edits. No new code. Each task deletes a self-contained layer, then verifies compilation passes before committing. Order: backend first (most isolated), then frontend, then infrastructure, then docs.
|
||||
|
||||
**Tech Stack:** Spring Boot 4 (Java 21, Maven), SvelteKit 2 / Svelte 5, Docker Compose, Paraglide i18n.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### Delete entirely
|
||||
- `backend/src/main/java/org/raddatz/familienarchiv/search/` — 14 Java source files
|
||||
- `backend/src/test/java/org/raddatz/familienarchiv/search/` — 6 Java test files
|
||||
- `frontend/src/routes/search/SmartModeToggle.svelte` + `.spec.ts`
|
||||
- `frontend/src/routes/search/SmartSearchStatus.svelte` + `.spec.ts`
|
||||
- `frontend/src/routes/search/InterpretationChipRow.svelte` + `.spec.ts`
|
||||
- `frontend/src/routes/search/DisambiguationPicker.svelte` + `.spec.ts`
|
||||
- `frontend/src/routes/search/chip-types.ts`
|
||||
- `frontend/src/routes/documents/theme-chip-removal.ts` + `.spec.ts`
|
||||
- `infra/observability/grafana/provisioning/dashboards/ollama.json`
|
||||
- `nlp-service/` (entire directory)
|
||||
- `docs/adr/028-nl-search-ollama.md`
|
||||
- `docs/adr/028-ollama-docker-compose-service.md`
|
||||
- `docs/adr/034-ollama-production-deployment-and-keep-alive.md`
|
||||
- `docs/adr/035-rule-based-nlp-service.md`
|
||||
|
||||
### Modify
|
||||
- `backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java` — remove 2 enum values
|
||||
- `backend/src/main/resources/application.yaml` — remove `nlp` + `nl-search` config blocks
|
||||
- `backend/src/main/resources/application-dev.yaml` — remove `nlp` config block
|
||||
- `frontend/src/routes/SearchFilterBar.svelte` — remove SmartModeToggle, smartMode prop, smart callbacks
|
||||
- `frontend/src/routes/SearchFilterBar.svelte.spec.ts` — remove smart-mode describe block
|
||||
- `frontend/src/routes/documents/+page.svelte` — remove all NL state, functions, template block
|
||||
- `frontend/src/lib/shared/errors.ts` — remove 2 error codes + their switch cases
|
||||
- `frontend/messages/de.json` — remove 8 smart-search keys
|
||||
- `frontend/messages/en.json` — remove 8 smart-search keys
|
||||
- `frontend/messages/es.json` — remove 8 smart-search keys
|
||||
- `docker-compose.yml` — remove nlp-service block + backend depends_on + env var
|
||||
- `docker-compose.prod.yml` — remove nlp-service block + backend depends_on + env var
|
||||
- `infra/observability/prometheus/prometheus.yml` — remove ollama scrape job
|
||||
- `CLAUDE.md` — remove search package reference + error code entries
|
||||
- `backend/CLAUDE.md` — no change needed (search package already absent from structure)
|
||||
- `frontend/CLAUDE.md` — update routes/search/ description
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Delete backend search package
|
||||
|
||||
**Files:**
|
||||
- Delete: `backend/src/main/java/org/raddatz/familienarchiv/search/` (14 files)
|
||||
- Delete: `backend/src/test/java/org/raddatz/familienarchiv/search/` (6 files)
|
||||
|
||||
- [ ] **Step 1: Delete all source files**
|
||||
|
||||
```bash
|
||||
rm -rf backend/src/main/java/org/raddatz/familienarchiv/search
|
||||
rm -rf backend/src/test/java/org/raddatz/familienarchiv/search
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify backend compiles**
|
||||
|
||||
```bash
|
||||
cd backend && . ~/.sdkman/candidates/java/current/bin/../.. && source ~/.sdkman/bin/sdkman-init.sh && ./mvnw compile -q
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESS with no errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor(search): delete backend NLP search package"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Remove ErrorCode entries and backend config
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java:138-142`
|
||||
- Modify: `backend/src/main/resources/application.yaml:133-138`
|
||||
- Modify: `backend/src/main/resources/application-dev.yaml:15-17`
|
||||
|
||||
- [ ] **Step 1: Remove NL Search enum values from ErrorCode.java**
|
||||
|
||||
Remove these lines (138–142):
|
||||
```java
|
||||
// --- NL Search ---
|
||||
/** Ollama is unreachable or timed out. 503 */
|
||||
SMART_SEARCH_UNAVAILABLE,
|
||||
/** NL search rate limit exceeded (5 requests per user per minute). 429 */
|
||||
SMART_SEARCH_RATE_LIMITED,
|
||||
```
|
||||
|
||||
The block between `TAG_MERGE_INVALID_TARGET,` and `// --- Generic ---` becomes empty.
|
||||
|
||||
- [ ] **Step 2: Remove nlp and nl-search config from application.yaml**
|
||||
|
||||
Remove these lines (133–138):
|
||||
```yaml
|
||||
nlp:
|
||||
base-url: http://nlp-service:8001
|
||||
|
||||
nl-search:
|
||||
rate-limit:
|
||||
max-requests-per-minute: 20
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove nlp config from application-dev.yaml**
|
||||
|
||||
Remove these lines (15–17):
|
||||
```yaml
|
||||
app:
|
||||
nlp:
|
||||
base-url: http://localhost:8001
|
||||
```
|
||||
|
||||
Note: only remove the `nlp:` sub-key under `app:`, preserving any other `app:` config above it.
|
||||
|
||||
- [ ] **Step 4: Verify backend still compiles**
|
||||
|
||||
```bash
|
||||
cd backend && source ~/.sdkman/bin/sdkman-init.sh && ./mvnw compile -q
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java \
|
||||
backend/src/main/resources/application.yaml \
|
||||
backend/src/main/resources/application-dev.yaml
|
||||
git commit -m "refactor(search): remove NLP error codes and application config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Delete frontend NL search components and utilities
|
||||
|
||||
**Files:**
|
||||
- Delete: `frontend/src/routes/search/SmartModeToggle.svelte` + `.spec.ts`
|
||||
- Delete: `frontend/src/routes/search/SmartSearchStatus.svelte` + `.spec.ts`
|
||||
- Delete: `frontend/src/routes/search/InterpretationChipRow.svelte` + `.spec.ts`
|
||||
- Delete: `frontend/src/routes/search/DisambiguationPicker.svelte` + `.spec.ts`
|
||||
- Delete: `frontend/src/routes/search/chip-types.ts`
|
||||
- Delete: `frontend/src/routes/documents/theme-chip-removal.ts` + `.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Delete all NL search components, specs, and utilities**
|
||||
|
||||
```bash
|
||||
rm frontend/src/routes/search/SmartModeToggle.svelte \
|
||||
frontend/src/routes/search/SmartModeToggle.svelte.spec.ts \
|
||||
frontend/src/routes/search/SmartSearchStatus.svelte \
|
||||
frontend/src/routes/search/SmartSearchStatus.svelte.spec.ts \
|
||||
frontend/src/routes/search/InterpretationChipRow.svelte \
|
||||
frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts \
|
||||
frontend/src/routes/search/DisambiguationPicker.svelte \
|
||||
frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts \
|
||||
frontend/src/routes/search/chip-types.ts \
|
||||
frontend/src/routes/documents/theme-chip-removal.ts \
|
||||
frontend/src/routes/documents/theme-chip-removal.spec.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor(search): delete frontend NLP search components and utilities"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Remove NL search from SearchFilterBar
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/routes/SearchFilterBar.svelte`
|
||||
- Modify: `frontend/src/routes/SearchFilterBar.svelte.spec.ts:199-233`
|
||||
|
||||
- [ ] **Step 1: Rewrite SearchFilterBar.svelte**
|
||||
|
||||
Replace the entire `<script>` block with:
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
||||
import TagInput from '$lib/tag/TagInput.svelte';
|
||||
import DateInput from '$lib/shared/primitives/DateInput.svelte';
|
||||
import SortDropdown from '$lib/shared/primitives/SortDropdown.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
q = $bindable(''),
|
||||
from = $bindable(''),
|
||||
to = $bindable(''),
|
||||
senderId = $bindable(''),
|
||||
receiverId = $bindable(''),
|
||||
tagNames = $bindable<{ name: string; id?: string; color?: string; parentId?: string }[]>([]),
|
||||
tagQ = $bindable(''),
|
||||
tagOperator = $bindable<'AND' | 'OR'>('AND'),
|
||||
undated = $bindable(false),
|
||||
undatedCount = 0,
|
||||
sort = $bindable('DATE'),
|
||||
dir = $bindable('desc'),
|
||||
showAdvanced = $bindable(false),
|
||||
initialSenderName = '',
|
||||
initialReceiverName = '',
|
||||
navKey = 0,
|
||||
isLoading = false,
|
||||
onSearch,
|
||||
onSearchImmediate,
|
||||
onfocus,
|
||||
onblur
|
||||
}: {
|
||||
q?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
senderId?: string;
|
||||
receiverId?: string;
|
||||
tagNames?: { name: string; id?: string; color?: string; parentId?: string }[];
|
||||
tagQ?: string;
|
||||
tagOperator?: 'AND' | 'OR';
|
||||
undated?: boolean;
|
||||
undatedCount?: number;
|
||||
sort?: string;
|
||||
dir?: string;
|
||||
showAdvanced?: boolean;
|
||||
initialSenderName?: string;
|
||||
initialReceiverName?: string;
|
||||
navKey?: number;
|
||||
isLoading?: boolean;
|
||||
onSearch: () => void;
|
||||
onSearchImmediate?: () => void;
|
||||
onfocus?: () => void;
|
||||
onblur?: () => void;
|
||||
} = $props();
|
||||
|
||||
// Plain (non-reactive) flag — not $state, so no reactive assignment inside $effect
|
||||
let sortDirMounted = false;
|
||||
|
||||
$effect(() => {
|
||||
void sort;
|
||||
void dir;
|
||||
if (!sortDirMounted) {
|
||||
sortDirMounted = true;
|
||||
return;
|
||||
}
|
||||
onSearch();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the search input element in the template**
|
||||
|
||||
Replace the `<input type="text" ...>` element (lines 92–105) with:
|
||||
```svelte
|
||||
<input
|
||||
type="text"
|
||||
bind:value={q}
|
||||
oninput={onSearch}
|
||||
onfocus={onfocus}
|
||||
onblur={onblur}
|
||||
aria-label={m.docs_search_placeholder()}
|
||||
placeholder={m.docs_search_placeholder()}
|
||||
class="block w-full border-line py-2.5 pl-10 pr-20 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove the SmartModeToggle component from the template**
|
||||
|
||||
Delete this line (135):
|
||||
```svelte
|
||||
<SmartModeToggle bind:smartMode={smartMode} onToggle={onModeToggle} />
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Remove smart-mode describe block from SearchFilterBar.svelte.spec.ts**
|
||||
|
||||
Delete lines 199–233 (the entire final `describe` block):
|
||||
```typescript
|
||||
describe('SearchFilterBar – smart-mode chip lifecycle hooks', () => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the SearchFilterBar tests to verify they pass**
|
||||
|
||||
```bash
|
||||
cd frontend && source ~/.nvm/nvm.sh && npm run test -- --project=client src/routes/SearchFilterBar.svelte.spec.ts
|
||||
```
|
||||
|
||||
Expected: all tests pass, no failures.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/routes/SearchFilterBar.svelte \
|
||||
frontend/src/routes/SearchFilterBar.svelte.spec.ts
|
||||
git commit -m "refactor(search): remove smart mode from SearchFilterBar"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Remove NL search from documents/+page.svelte
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/routes/documents/+page.svelte`
|
||||
|
||||
This is the largest edit. Remove all NL search state, derived values, functions, and the NL results template block.
|
||||
|
||||
- [ ] **Step 1: Remove NL search imports (lines 11–16, 23–27)**
|
||||
|
||||
Remove these import lines:
|
||||
```typescript
|
||||
import SmartSearchStatus from '../search/SmartSearchStatus.svelte';
|
||||
import InterpretationChipRow from '../search/InterpretationChipRow.svelte';
|
||||
import type { ChipType } from '../search/chip-types.js';
|
||||
import { buildThemeRemovalUrl } from './theme-chip-removal.js';
|
||||
import DisambiguationPicker from '../search/DisambiguationPicker.svelte';
|
||||
```
|
||||
|
||||
Remove these type aliases:
|
||||
```typescript
|
||||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||
type NlSearchResponse = components['schemas']['NlSearchResponse'];
|
||||
type DocumentSearchResult = components['schemas']['DocumentSearchResult'];
|
||||
type PersonHint = components['schemas']['PersonHint'];
|
||||
type SmartSearchErrorCode = 'SMART_SEARCH_UNAVAILABLE' | 'SMART_SEARCH_RATE_LIMITED';
|
||||
```
|
||||
|
||||
Also remove the `import { csrfFetch } from '$lib/shared/cookies';` line — it is only used by `runSmartSearch`.
|
||||
|
||||
- [ ] **Step 2: Remove all NL state and derived values (lines 51–70)**
|
||||
|
||||
Remove these declarations:
|
||||
```typescript
|
||||
// Smart (NL) search — UI-local state, resets on real page navigation (away + back).
|
||||
let smartMode = $state(false);
|
||||
let nlSubmitted = $state(false);
|
||||
let nlLoading = $state(false);
|
||||
let nlError = $state<SmartSearchErrorCode | null>(null);
|
||||
let nlInterpretation = $state<NlQueryInterpretation | null>(null);
|
||||
let nlResult = $state<DocumentSearchResult | null>(null);
|
||||
|
||||
const showNlView = $derived(smartMode && nlSubmitted);
|
||||
const nlHasResults = $derived((nlResult?.items.length ?? 0) > 0);
|
||||
const ambiguousPersons = $derived(nlInterpretation?.ambiguousPersons ?? []);
|
||||
const nlIsAmbiguous = $derived(ambiguousPersons.length > 0);
|
||||
const disambiguationHeading = $derived(
|
||||
ambiguousPersons.length === 1
|
||||
? m.search_disambiguation_did_you_mean({ name: ambiguousPersons[0].displayName })
|
||||
: m.search_disambiguation_heading()
|
||||
);
|
||||
const showDisambiguationCue = $derived(ambiguousPersons.length >= 2);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove all NL search functions (lines 202–318)**
|
||||
|
||||
Remove these functions entirely:
|
||||
- `resetNlState()`
|
||||
- `onModeToggle()`
|
||||
- `runSmartSearch()`
|
||||
- `switchToKeywordMode()`
|
||||
- `applyResolvedAndSearch()`
|
||||
- `paramsFromInterpretation()`
|
||||
- `removeChip()`
|
||||
- `selectDisambiguated()`
|
||||
|
||||
- [ ] **Step 4: Update SearchFilterBar usage in the template**
|
||||
|
||||
Replace the SearchFilterBar call with (removing `bind:smartMode`, `onSmartSearch`, `onModeToggle`):
|
||||
```svelte
|
||||
<SearchFilterBar
|
||||
bind:q={q}
|
||||
bind:from={from}
|
||||
bind:to={to}
|
||||
bind:senderId={senderId}
|
||||
bind:receiverId={receiverId}
|
||||
bind:tagNames={tagNames}
|
||||
bind:showAdvanced={showAdvanced}
|
||||
bind:sort={sort}
|
||||
bind:dir={dir}
|
||||
bind:tagQ={tagQ}
|
||||
bind:tagOperator={tagOperator}
|
||||
bind:undated={undated}
|
||||
undatedCount={data.undatedCount ?? 0}
|
||||
initialSenderName={initialSenderName}
|
||||
initialReceiverName={initialReceiverName}
|
||||
navKey={navKey}
|
||||
isLoading={navigating.to !== null}
|
||||
onSearch={handleTextSearch}
|
||||
onSearchImmediate={handleImmediateSearch}
|
||||
onfocus={() => (qFocused = true)}
|
||||
onblur={() => (qFocused = false)}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Remove the NL results template block**
|
||||
|
||||
Replace the entire `{#if showNlView}...{:else}...{/if}` block with just the content of the `{:else}` branch — the `<div class="mt-3 mb-4 hidden lg:block">` block through the closing `{/if}`, unwrapped. The result should be:
|
||||
|
||||
```svelte
|
||||
<div class="mt-3 mb-4 hidden lg:block">
|
||||
<TimelineDensityFilter
|
||||
... (keep as-is)
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex items-center justify-between gap-4">
|
||||
... (keep as-is)
|
||||
</div>
|
||||
|
||||
<DocumentList ... (keep as-is) />
|
||||
|
||||
<Pagination ... (keep as-is) />
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify frontend type-checks cleanly for this file**
|
||||
|
||||
```bash
|
||||
cd frontend && source ~/.nvm/nvm.sh && npm run check 2>&1 | grep "documents/+page.svelte"
|
||||
```
|
||||
|
||||
Expected: no errors for `+page.svelte` (pre-existing errors in other files are acceptable).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/routes/documents/+page.svelte
|
||||
git commit -m "refactor(search): remove NLP smart search from documents page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Remove error codes from frontend errors.ts
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/shared/errors.ts`
|
||||
|
||||
- [ ] **Step 1: Remove error code union members (lines 56–57)**
|
||||
|
||||
Remove from the `ErrorCode` type union:
|
||||
```typescript
|
||||
| 'SMART_SEARCH_UNAVAILABLE'
|
||||
| 'SMART_SEARCH_RATE_LIMITED'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove switch cases (lines 183–186)**
|
||||
|
||||
Remove from the `getErrorMessage()` switch:
|
||||
```typescript
|
||||
case 'SMART_SEARCH_UNAVAILABLE':
|
||||
return m.error_smart_search_unavailable();
|
||||
case 'SMART_SEARCH_RATE_LIMITED':
|
||||
return m.error_smart_search_rate_limited();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/lib/shared/errors.ts
|
||||
git commit -m "refactor(search): remove smart search error codes from frontend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Remove i18n messages from all three language files
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/messages/de.json`
|
||||
- Modify: `frontend/messages/en.json`
|
||||
- Modify: `frontend/messages/es.json`
|
||||
|
||||
Remove the same set of keys from each file. The keys to remove are:
|
||||
|
||||
```
|
||||
"error_smart_search_unavailable"
|
||||
"error_smart_search_rate_limited"
|
||||
"smart_search_keywords_not_applied"
|
||||
"search_toggle_smart_label"
|
||||
"search_toggle_smart_label_suffix"
|
||||
"search_toggle_keyword_label"
|
||||
"search_toggle_keyword_label_suffix"
|
||||
"search_error_unavailable"
|
||||
"search_error_unavailable_body"
|
||||
"search_error_rate_limited"
|
||||
"search_error_rate_limited_body"
|
||||
"search_empty_nl"
|
||||
"search_empty_retry_keyword"
|
||||
"search_disambiguation_did_you_mean"
|
||||
"search_disambiguation_heading"
|
||||
```
|
||||
|
||||
Note: not all keys may exist in all three files — remove whichever are present.
|
||||
|
||||
- [ ] **Step 1: Remove smart-search keys from de.json**
|
||||
|
||||
Open `frontend/messages/de.json` and delete every key listed above.
|
||||
|
||||
- [ ] **Step 2: Remove smart-search keys from en.json**
|
||||
|
||||
Open `frontend/messages/en.json` and delete every key listed above.
|
||||
|
||||
- [ ] **Step 3: Remove smart-search keys from es.json**
|
||||
|
||||
Open `frontend/messages/es.json` and delete every key listed above.
|
||||
|
||||
- [ ] **Step 4: Verify JSON is still valid**
|
||||
|
||||
```bash
|
||||
cd frontend && node -e "
|
||||
['messages/de.json','messages/en.json','messages/es.json'].forEach(f => {
|
||||
try { JSON.parse(require('fs').readFileSync(f,'utf8')); console.log(f+': OK'); }
|
||||
catch(e) { console.error(f+': INVALID - '+e.message); process.exit(1); }
|
||||
})
|
||||
"
|
||||
```
|
||||
|
||||
Expected: all three files print `OK`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/messages/de.json frontend/messages/en.json frontend/messages/es.json
|
||||
git commit -m "refactor(search): remove smart search i18n keys from all language files"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Remove nlp-service from docker-compose files
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker-compose.yml`
|
||||
- Modify: `docker-compose.prod.yml`
|
||||
|
||||
#### docker-compose.yml
|
||||
|
||||
- [ ] **Step 1: Remove the nlp-service service block**
|
||||
|
||||
Delete the entire `nlp-service:` top-level service definition (lines ~148–179, from ` nlp-service:` through its closing line before the next service).
|
||||
|
||||
- [ ] **Step 2: Remove nlp-service from backend depends_on**
|
||||
|
||||
Find the `backend:` service's `depends_on:` list and remove the `nlp-service:` entry.
|
||||
|
||||
- [ ] **Step 3: Remove APP_NLP_BASE_URL from backend environment**
|
||||
|
||||
Find the `backend:` service's `environment:` section and remove:
|
||||
```yaml
|
||||
APP_NLP_BASE_URL: "http://nlp-service:8001"
|
||||
```
|
||||
|
||||
#### docker-compose.prod.yml
|
||||
|
||||
- [ ] **Step 4: Remove the nlp-service service block**
|
||||
|
||||
Delete the entire `nlp-service:` top-level service definition (lines ~206–221, from ` nlp-service:` through its closing line).
|
||||
|
||||
- [ ] **Step 5: Remove nlp-service from backend depends_on**
|
||||
|
||||
Find the `backend:` service's `depends_on:` list and remove the `nlp-service:` entry.
|
||||
|
||||
- [ ] **Step 6: Remove APP_NLP_BASE_URL from backend environment**
|
||||
|
||||
Find the `backend:` service's `environment:` section and remove:
|
||||
```yaml
|
||||
APP_NLP_BASE_URL: http://nlp-service:8001
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Validate compose files parse correctly**
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml config --quiet && echo "dev: OK"
|
||||
docker compose -f docker-compose.prod.yml config --quiet && echo "prod: OK"
|
||||
```
|
||||
|
||||
Expected: both print `OK` (warnings are acceptable, errors are not).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add docker-compose.yml docker-compose.prod.yml
|
||||
git commit -m "refactor(infra): remove nlp-service from docker-compose files"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Remove observability config for Ollama/NLP
|
||||
|
||||
**Files:**
|
||||
- Modify: `infra/observability/prometheus/prometheus.yml`
|
||||
- Delete: `infra/observability/grafana/provisioning/dashboards/ollama.json`
|
||||
|
||||
- [ ] **Step 1: Remove ollama scrape job from prometheus.yml**
|
||||
|
||||
Delete these lines from `infra/observability/prometheus/prometheus.yml`:
|
||||
```yaml
|
||||
- job_name: ollama
|
||||
static_configs:
|
||||
- targets: ['ollama:11434']
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Delete the Grafana Ollama dashboard**
|
||||
|
||||
```bash
|
||||
rm infra/observability/grafana/provisioning/dashboards/ollama.json
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add infra/observability/prometheus/prometheus.yml
|
||||
git add -A infra/observability/grafana/provisioning/dashboards/
|
||||
git commit -m "refactor(infra): remove Ollama/NLP observability config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Delete nlp-service directory and ADR docs
|
||||
|
||||
**Files:**
|
||||
- Delete: `nlp-service/` (entire directory)
|
||||
- Delete: `docs/adr/028-nl-search-ollama.md`
|
||||
- Delete: `docs/adr/028-ollama-docker-compose-service.md`
|
||||
- Delete: `docs/adr/034-ollama-production-deployment-and-keep-alive.md`
|
||||
- Delete: `docs/adr/035-rule-based-nlp-service.md`
|
||||
|
||||
- [ ] **Step 1: Delete the nlp-service microservice**
|
||||
|
||||
```bash
|
||||
rm -rf nlp-service/
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Delete NLP/Ollama ADRs**
|
||||
|
||||
```bash
|
||||
rm docs/adr/028-nl-search-ollama.md \
|
||||
docs/adr/028-ollama-docker-compose-service.md \
|
||||
docs/adr/034-ollama-production-deployment-and-keep-alive.md \
|
||||
docs/adr/035-rule-based-nlp-service.md
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor(search): delete nlp-service microservice and Ollama ADRs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Update CLAUDE.md files
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
- Modify: `frontend/CLAUDE.md`
|
||||
|
||||
#### CLAUDE.md (root)
|
||||
|
||||
- [ ] **Step 1: Remove search package from backend package structure table**
|
||||
|
||||
Delete this row from the Package Structure section:
|
||||
```
|
||||
├── search/ NL search domain — NlSearchController, NlQueryParserService, RestClientOllamaClient, NlSearchRateLimiter
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove NLP error codes from Error Handling section**
|
||||
|
||||
In the **Error Handling** LLM reminder, remove:
|
||||
- `SMART_SEARCH_UNAVAILABLE (HTTP 503 — Ollama inference service offline or timed out)`
|
||||
- `SMART_SEARCH_RATE_LIMITED (HTTP 429 — user exceeded 5 NL search requests per minute)`
|
||||
|
||||
from both the Backend Architecture and Frontend Architecture sections.
|
||||
|
||||
#### frontend/CLAUDE.md
|
||||
|
||||
- [ ] **Step 3: Update routes/search/ description**
|
||||
|
||||
Replace:
|
||||
```
|
||||
│ ├── search/ # Smart (NL) search sub-components — SmartModeToggle, InterpretationChipRow, SmartSearchStatus, DisambiguationPicker (no +page; consumed by documents/ and SearchFilterBar)
|
||||
```
|
||||
|
||||
With:
|
||||
```
|
||||
│ ├── search/ # (empty — NL search removed)
|
||||
```
|
||||
|
||||
Or delete the line entirely if the directory will be empty after removing all component files.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md frontend/CLAUDE.md
|
||||
git commit -m "docs(claude): remove NLP search references from CLAUDE.md files"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Verify and regenerate API types
|
||||
|
||||
- [ ] **Step 1: Install frontend dependencies if needed**
|
||||
|
||||
```bash
|
||||
cd frontend && source ~/.nvm/nvm.sh && npm install
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run frontend vitest to verify no regressions**
|
||||
|
||||
```bash
|
||||
cd frontend && source ~/.nvm/nvm.sh && npm run test -- --reporter=verbose 2>&1 | tail -30
|
||||
```
|
||||
|
||||
Expected: all tests pass. The deleted spec files are gone and no longer run.
|
||||
|
||||
- [ ] **Step 3: Start backend to regenerate API types (requires Docker stack)**
|
||||
|
||||
If backend is running locally:
|
||||
```bash
|
||||
cd frontend && source ~/.nvm/nvm.sh && npm run generate:api
|
||||
```
|
||||
|
||||
If not running, skip — the generated types remain valid until the next backend change (no NLP types are referenced after this plan).
|
||||
|
||||
- [ ] **Step 4: Run a final backend compile**
|
||||
|
||||
```bash
|
||||
cd backend && source ~/.sdkman/bin/sdkman-init.sh && ./mvnw compile -q
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESS.
|
||||
|
||||
- [ ] **Step 5: Commit regenerated API types (if generate:api was run)**
|
||||
|
||||
```bash
|
||||
git add frontend/src/lib/generated/
|
||||
git commit -m "chore(api): regenerate API types after NLP search removal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Check and clean the empty search/ directory
|
||||
|
||||
- [ ] **Step 1: Check if routes/search/ is now empty**
|
||||
|
||||
```bash
|
||||
ls frontend/src/routes/search/
|
||||
```
|
||||
|
||||
If only `chip-types.ts` was in there (already deleted in Task 3) and all Svelte files are gone, the directory should be empty.
|
||||
|
||||
- [ ] **Step 2: Delete the empty directory**
|
||||
|
||||
```bash
|
||||
rmdir frontend/src/routes/search/ 2>/dev/null && echo "deleted" || echo "not empty — check contents"
|
||||
```
|
||||
|
||||
If not empty, list what remains and delete the stray files.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor(search): remove empty search/ route directory"
|
||||
```
|
||||
Reference in New Issue
Block a user