feat(search): auto-select a single direct person match in smart search (#763) #769
@@ -0,0 +1,13 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Result of {@link PersonService#resolveByName(String)}: candidate persons split by name-match
|
||||
* strength. {@code direct} = every query token is a whole-token match across the person's name
|
||||
* components (alias/maiden-name aware); {@code partial} = matched the substring fetch but is not
|
||||
* direct. The vocabulary is deliberately name-match strength ({@code direct}/{@code partial}), not
|
||||
* the search layer's resolved/ambiguous buckets — the caller maps these into its own outcome.
|
||||
*/
|
||||
public record NameMatches(List<Person> direct, List<Person> partial) {
|
||||
}
|
||||
@@ -19,7 +19,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
"LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(a.firstName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||
"ORDER BY p.lastName ASC, p.firstName ASC")
|
||||
List<Person> searchByName(@Param("query") String query);
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
@@ -24,11 +30,20 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PersonService {
|
||||
|
||||
// Co-located with the fetch loop that owns them (issue #763). MAX_TOKENS caps the number of
|
||||
// unindexed leading-wildcard LIKE scans per name — a DoS control, not just perf. MAX_CANDIDATES
|
||||
// bounds each result bucket and is applied AFTER classification so a direct match that sorts
|
||||
// past position 10 among partials is never discarded.
|
||||
private static final int MAX_TOKENS = 8;
|
||||
private static final int MAX_CANDIDATES = 10;
|
||||
|
||||
private final PersonRepository personRepository;
|
||||
private final PersonNameAliasRepository aliasRepository;
|
||||
|
||||
@@ -103,6 +118,92 @@ public class PersonService {
|
||||
return personRepository.searchByName(fragment);
|
||||
}
|
||||
|
||||
// Name-match tokenizer (issue #763): lowercase, split on whitespace/hyphen/apostrophe,
|
||||
// drop empties. Applied symmetrically to the query and to every candidate name component so
|
||||
// that "Anna-Maria" and "Anna Maria" tokenize alike. Order-preserving for deterministic tests.
|
||||
static Set<String> tokenize(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return Set.of();
|
||||
}
|
||||
LinkedHashSet<String> tokens = new LinkedHashSet<>();
|
||||
for (String part : raw.toLowerCase(Locale.ROOT).split("[\\s\\-']+")) {
|
||||
if (!part.isEmpty()) {
|
||||
tokens.add(part);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an extracted person name into {@link NameMatches} by name-match strength.
|
||||
* Orchestrates tokenize → cap → fetch pool → classify → cap-after-classify. Read-only
|
||||
* transaction keeps the Hibernate session open so each candidate's lazy {@code nameAliases}
|
||||
* are reachable during classification (see ADR-022).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public NameMatches resolveByName(String name) {
|
||||
Set<String> queryTokens = capTokens(tokenize(name));
|
||||
if (queryTokens.isEmpty()) {
|
||||
log.debug("resolveByName outcome=no-match tokens=0");
|
||||
return new NameMatches(List.of(), List.of());
|
||||
}
|
||||
return classify(fetchPool(queryTokens), queryTokens);
|
||||
}
|
||||
|
||||
private Set<String> capTokens(Set<String> tokens) {
|
||||
return tokens.stream().limit(MAX_TOKENS).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
private List<Person> fetchPool(Set<String> queryTokens) {
|
||||
LinkedHashMap<UUID, Person> pool = new LinkedHashMap<>();
|
||||
for (String token : queryTokens) {
|
||||
for (Person candidate : findByDisplayNameContaining(token)) {
|
||||
pool.putIfAbsent(candidate.getId(), candidate);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(pool.values());
|
||||
}
|
||||
|
||||
private NameMatches classify(List<Person> pool, Set<String> queryTokens) {
|
||||
List<Person> direct = new ArrayList<>();
|
||||
List<Person> partial = new ArrayList<>();
|
||||
for (Person candidate : pool) {
|
||||
if (personTokens(candidate).containsAll(queryTokens)) {
|
||||
direct.add(candidate);
|
||||
} else {
|
||||
partial.add(candidate);
|
||||
}
|
||||
}
|
||||
List<Person> cappedDirect = cap(direct);
|
||||
List<Person> cappedPartial = cap(partial);
|
||||
log.debug("resolveByName outcome={} tokens={}", outcome(cappedDirect, cappedPartial), queryTokens.size());
|
||||
return new NameMatches(cappedDirect, cappedPartial);
|
||||
}
|
||||
|
||||
private static Set<String> personTokens(Person person) {
|
||||
Set<String> tokens = new LinkedHashSet<>();
|
||||
tokens.addAll(tokenize(person.getFirstName()));
|
||||
tokens.addAll(tokenize(person.getLastName()));
|
||||
tokens.addAll(tokenize(person.getAlias()));
|
||||
tokens.addAll(tokenize(person.getTitle()));
|
||||
for (PersonNameAlias alias : person.getNameAliases()) {
|
||||
tokens.addAll(tokenize(alias.getFirstName()));
|
||||
tokens.addAll(tokenize(alias.getLastName()));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static List<Person> cap(List<Person> people) {
|
||||
return people.size() > MAX_CANDIDATES ? people.subList(0, MAX_CANDIDATES) : people;
|
||||
}
|
||||
|
||||
private static String outcome(List<Person> direct, List<Person> partial) {
|
||||
if (direct.size() == 1) return "direct=1";
|
||||
if (direct.size() >= 2) return "direct>=2";
|
||||
if (!partial.isEmpty()) return "partial-only";
|
||||
return "no-match";
|
||||
}
|
||||
|
||||
public List<Person> findAllFamilyMembers() {
|
||||
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ Features: person CRUD, name alias management, person merge (deduplication), fami
|
||||
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
|
||||
| `findAll(String q)` | document, dashboard | List all persons |
|
||||
| `findByName(String firstName, String lastName)` | document | Filename-based **sender resolution** in `storeDocument`: exact-case match → single case-insensitive match → else **empty** (ambiguous names leave the sender unset; a null first name never matches). See ADR-033. |
|
||||
| `resolveByName(String name)` | search | NL-search name resolution returning `NameMatches` (direct vs partial). Token/word-boundary, alias-aware matching so a single direct match auto-selects even when looser substring hits coexist ("Clara Cram" vs "Clara Cramer"). See #763. |
|
||||
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally. Resolves exact-case → lowest-id case-insensitive sibling → create — never throws on case-colliding aliases. See ADR-033. |
|
||||
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
|
||||
| `findCorrespondents()` | document | Correspondent list for conversation filter |
|
||||
|
||||
@@ -15,8 +15,12 @@ public record NlQueryInterpretation(
|
||||
@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
|
||||
boolean keywordsApplied,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
boolean tagsApplied
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -8,14 +8,18 @@ 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;
|
||||
|
||||
@@ -27,11 +31,13 @@ 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 MAX_CANDIDATES = 10;
|
||||
private static final int MIN_TAG_TERM = 3;
|
||||
private static final int MAX_RESOLVED_TAGS = 10;
|
||||
|
||||
private final OllamaClient ollamaClient;
|
||||
private final PersonService personService;
|
||||
private final DocumentService documentService;
|
||||
private final TagService tagService;
|
||||
|
||||
public NlSearchResponse search(String query, Pageable pageable) {
|
||||
if (query == null || query.length() < MIN_QUERY || query.length() > MAX_QUERY) {
|
||||
@@ -44,13 +50,18 @@ public class NlQueryParserService {
|
||||
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, ext.rawQuery(), false);
|
||||
keywords, List.of(), ext.rawQuery(), false, false);
|
||||
return new NlSearchResponse(DocumentSearchResult.of(List.of()), interpretation);
|
||||
}
|
||||
|
||||
@@ -58,31 +69,35 @@ public class NlQueryParserService {
|
||||
List<String> noMatchFragments = resolution.noMatchFragments();
|
||||
List<String> extraFragments = resolution.extraFragments();
|
||||
|
||||
String text = buildText(keywords, noMatchFragments, extraFragments, ext.rawQuery());
|
||||
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, ext.rawQuery(), false);
|
||||
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,
|
||||
List.of(), null,
|
||||
null, TagOperator.AND, false);
|
||||
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, ext.rawQuery(), keywordsApplied);
|
||||
resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, resolvedTagHints, ext.rawQuery(), keywordsApplied, tagsApplied);
|
||||
return new NlSearchResponse(docs, interpretation);
|
||||
}
|
||||
|
||||
@@ -98,38 +113,72 @@ public class NlQueryParserService {
|
||||
log.debug("Skipping name fragment (too long or null): length={}", name == null ? 0 : name.length());
|
||||
continue;
|
||||
}
|
||||
List<Person> candidates = personService.findByDisplayNameContaining(name);
|
||||
List<Person> capped = candidates.size() > MAX_CANDIDATES
|
||||
? candidates.subList(0, MAX_CANDIDATES)
|
||||
: candidates;
|
||||
NameMatches matches = personService.resolveByName(name);
|
||||
List<Person> direct = matches.direct();
|
||||
List<Person> partial = matches.partial();
|
||||
|
||||
if (capped.isEmpty()) {
|
||||
noMatchFragments.add(name);
|
||||
} else if (capped.size() == 1) {
|
||||
Person p = capped.get(0);
|
||||
PersonHint hint = new PersonHint(p.getId(), p.getDisplayName());
|
||||
if (direct.size() == 1) {
|
||||
Person p = direct.get(0);
|
||||
resolvedIndex++;
|
||||
if (resolvedIndex <= 2) {
|
||||
resolved.add(hint);
|
||||
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 {
|
||||
capped.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName())));
|
||||
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);
|
||||
|
||||
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) {
|
||||
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() && rawQuery != null && !rawQuery.isBlank()) {
|
||||
if (text.isBlank() && !hadStructuredMatch && rawQuery != null && !rawQuery.isBlank()) {
|
||||
return rawQuery;
|
||||
}
|
||||
return text;
|
||||
@@ -157,4 +206,10 @@ public class NlQueryParserService {
|
||||
List<String> noMatchFragments,
|
||||
List<String> extraFragments
|
||||
) {}
|
||||
|
||||
private record TagResolution(
|
||||
List<TagHint> hints,
|
||||
List<String> tagNames,
|
||||
List<String> remaining
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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
|
||||
) {
|
||||
}
|
||||
@@ -46,6 +46,10 @@ public class TagService {
|
||||
return enrichWithRelatives(matched);
|
||||
}
|
||||
|
||||
public List<Tag> findByNameContaining(String fragment) {
|
||||
return tagRepository.findByNameContainingIgnoreCase(fragment);
|
||||
}
|
||||
|
||||
public Tag getById(UUID id) {
|
||||
return tagRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));
|
||||
|
||||
@@ -428,6 +428,67 @@ class PersonRepositoryTest {
|
||||
assertThat(results).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByName_findsByAliasFirstName() {
|
||||
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
|
||||
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||
|
||||
List<Person> results = personRepository.searchByName("Wilhelmina");
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByName_ordersByLastNameThenFirstName() {
|
||||
personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
personRepository.save(Person.builder().firstName("Anna").lastName("Cram").build());
|
||||
personRepository.save(Person.builder().firstName("Bernd").lastName("Cram").build());
|
||||
|
||||
List<Person> results = personRepository.searchByName("Cram");
|
||||
|
||||
assertThat(results).extracting(Person::getFirstName)
|
||||
.containsExactly("Anna", "Bernd", "Clara");
|
||||
}
|
||||
|
||||
// ─── resolveByName fetch→classify, end-to-end on real Postgres (#763 review) ───
|
||||
// The classifier unit tests in PersonServiceTest stub searchByName, so they never prove the
|
||||
// fetch query actually finds an alias-only match and feeds it into classification. These walk
|
||||
// the whole searchByName → resolveByName path over the real Postgres slice, closing AC#4/#5.
|
||||
|
||||
@Test
|
||||
void resolveByName_maidenAlias_classifiesAsDirect_endToEnd() {
|
||||
PersonService personService = new PersonService(personRepository, aliasRepository);
|
||||
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Müller").build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).lastName("Cram").type(PersonNameAliasType.MAIDEN_NAME).sortOrder(0).build());
|
||||
// Detach so resolveByName re-fetches with its lazy nameAliases loaded from the DB —
|
||||
// the fresh-session behaviour the @Transactional(readOnly=true) path has in production.
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
NameMatches matches = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_aliasFirstName_classifiesAsDirect_endToEnd() {
|
||||
PersonService personService = new PersonService(personRepository, aliasRepository);
|
||||
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
|
||||
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
NameMatches matches = personService.resolveByName("Wilhelmina");
|
||||
|
||||
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
|
||||
}
|
||||
|
||||
// ─── searchWithDocumentCount with aliases ────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -909,4 +909,154 @@ class PersonServiceTest {
|
||||
assertThat(result).containsExactly(walter);
|
||||
verify(personRepository).searchByName("Walter");
|
||||
}
|
||||
|
||||
// ─── tokenize (name-match contract) ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void tokenize_hyphenatedName_splitsOnHyphen() {
|
||||
assertThat(PersonService.tokenize("Anna-Maria")).containsExactly("anna", "maria");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_apostropheName_splitsOnApostrophe() {
|
||||
assertThat(PersonService.tokenize("D'Angelo")).containsExactly("d", "angelo");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_umlautName_lowercasesToSingleToken() {
|
||||
assertThat(PersonService.tokenize("Müller")).containsExactly("müller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_doubleSpace_dropsEmptyTokens() {
|
||||
assertThat(PersonService.tokenize("Clara Cram")).containsExactly("clara", "cram");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_allWhitespace_returnsEmpty() {
|
||||
assertThat(PersonService.tokenize(" ")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_null_returnsEmpty() {
|
||||
assertThat(PersonService.tokenize(null)).isEmpty();
|
||||
}
|
||||
|
||||
// ─── resolveByName (direct / partial classification) ──────────────────────
|
||||
|
||||
@Test
|
||||
void resolveByName_singleDirectMatch_classifiesAsDirect() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_maidenAliasToken_classifiesAsDirect() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Müller")
|
||||
.nameAliases(List.of(PersonNameAlias.builder().lastName("Cram")
|
||||
.type(PersonNameAliasType.MAIDEN_NAME).build()))
|
||||
.build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_aliasFirstNameToken_isFetchedAndClassified() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram")
|
||||
.nameAliases(List.of(PersonNameAlias.builder().firstName("Wilhelmina").lastName("de Gruyter")
|
||||
.type(PersonNameAliasType.BIRTH).build()))
|
||||
.build();
|
||||
when(personRepository.searchByName("wilhelmina")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Wilhelmina");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_middleName_stillDirect() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara Maria").lastName("Cram").build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_reorderedTokens_stillDirect() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Cram Clara");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_cramVsCramer_classifiesAsPartial() {
|
||||
Person cramer = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(cramer));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(cramer));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.partial()).containsExactly(cramer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_emptyAfterTokenizing_returnsNoCandidates() {
|
||||
NameMatches result = personService.resolveByName(" - ");
|
||||
|
||||
assertThat(result.direct()).isEmpty();
|
||||
verify(personRepository, never()).searchByName(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_directSortsBeyondCap_stillReturnedAsDirect() {
|
||||
List<Person> pool = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < 10; i++) {
|
||||
pool.add(Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build());
|
||||
}
|
||||
Person direct = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||
pool.add(direct);
|
||||
when(personRepository.searchByName("clara")).thenReturn(pool);
|
||||
when(personRepository.searchByName("cram")).thenReturn(pool);
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).containsExactly(direct);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_over8Tokens_issuesAtMost8Fetches() {
|
||||
personService.resolveByName("a b c d e f g h i j");
|
||||
|
||||
verify(personRepository, org.mockito.Mockito.atMost(8)).searchByName(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_samePersonFromTwoTokens_appearsOnce() {
|
||||
// Both token fetches return the same person id — fetchPool's putIfAbsent must dedup so the
|
||||
// candidate is classified once, not twice.
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).hasSize(1);
|
||||
assertThat(result.partial()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,18 @@ 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;
|
||||
|
||||
@@ -32,6 +36,7 @@ class NlQueryParserServiceTest {
|
||||
@Mock OllamaClient ollamaClient;
|
||||
@Mock PersonService personService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock TagService tagService;
|
||||
|
||||
NlQueryParserService service;
|
||||
|
||||
@@ -40,11 +45,12 @@ class NlQueryParserServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
service = new NlQueryParserService(ollamaClient, personService, documentService);
|
||||
service = new NlQueryParserService(ollamaClient, 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 ---
|
||||
@@ -59,6 +65,18 @@ class NlQueryParserServiceTest {
|
||||
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");
|
||||
@@ -70,7 +88,7 @@ class NlQueryParserServiceTest {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
||||
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
|
||||
NlSearchResponse resp = service.search("Was hat Walter geschrieben?", PAGE);
|
||||
|
||||
@@ -91,7 +109,7 @@ class NlQueryParserServiceTest {
|
||||
Person b = person(UUID.randomUUID(), "Walter", "Schmidt");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
||||
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(a, b));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(a, b)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
||||
|
||||
@@ -109,7 +127,7 @@ class NlQueryParserServiceTest {
|
||||
Person b = person(UUID.randomUUID(), "Emma", "Raddatz");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Emma"), "any", null, null, List.of()));
|
||||
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(a, b));
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(a, b)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe an Emma", PAGE);
|
||||
|
||||
@@ -124,7 +142,7 @@ class NlQueryParserServiceTest {
|
||||
void search_noMatchName_isFoldedIntoText() {
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Karl"), "any", null, null, List.of()));
|
||||
when(personService.findByDisplayNameContaining("Karl")).thenReturn(List.of());
|
||||
when(personService.resolveByName("Karl")).thenReturn(makeNameMatches());
|
||||
|
||||
service.search("Briefe von Karl", PAGE);
|
||||
|
||||
@@ -142,7 +160,7 @@ class NlQueryParserServiceTest {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "any", null, null, List.of()));
|
||||
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
||||
|
||||
@@ -159,8 +177,8 @@ class NlQueryParserServiceTest {
|
||||
Person emma = person(P2, "Emma", "Raddatz");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter", "Emma"), "any", null, null, List.of()));
|
||||
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
||||
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma));
|
||||
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", PAGE);
|
||||
|
||||
@@ -181,8 +199,8 @@ class NlQueryParserServiceTest {
|
||||
Person emma2 = person(P3, "Emma", "Schmidt");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter", "Emma"), "sender", null, null, List.of()));
|
||||
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
||||
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma1, emma2));
|
||||
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", PAGE);
|
||||
|
||||
@@ -197,8 +215,8 @@ class NlQueryParserServiceTest {
|
||||
Person emma = person(P2, "Emma", "Raddatz");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Karl", "Emma"), "sender", null, null, List.of()));
|
||||
when(personService.findByDisplayNameContaining("Karl")).thenReturn(List.of());
|
||||
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma));
|
||||
when(personService.resolveByName("Karl")).thenReturn(makeNameMatches());
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||
|
||||
service.search("Briefe von Karl an Emma", PAGE);
|
||||
|
||||
@@ -217,9 +235,9 @@ class NlQueryParserServiceTest {
|
||||
Person heinrich = person(P3, "Heinrich", "Braun");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter", "Emma", "Heinrich"), "any", null, null, List.of()));
|
||||
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
||||
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma));
|
||||
when(personService.findByDisplayNameContaining("Heinrich")).thenReturn(List.of(heinrich));
|
||||
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", PAGE);
|
||||
|
||||
@@ -338,7 +356,7 @@ class NlQueryParserServiceTest {
|
||||
// but NlQueryParserService must also be safe if something unexpected arrives.
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(new OllamaExtraction(List.of("Walter"), "unknown_role", null, null, List.of(), "query"));
|
||||
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
||||
|
||||
@@ -369,20 +387,21 @@ class NlQueryParserServiceTest {
|
||||
|
||||
service.search("Briefe von sehr langem Namen", PAGE);
|
||||
|
||||
verify(personService, never()).findByDisplayNameContaining(anyString());
|
||||
verify(personService, never()).resolveByName(anyString());
|
||||
}
|
||||
|
||||
// --- 20. Max 10 candidates cap: 11 persons returned → only first 10 in ambiguousPersons ---
|
||||
// --- 20. Cap lives in resolveByName (after classification): a pre-capped 10-direct result
|
||||
// maps straight to ambiguousPersons; the search layer adds no second cap. ---
|
||||
|
||||
@Test
|
||||
void search_elevenCandidates_capsAtTen() {
|
||||
List<Person> eleven = new ArrayList<>();
|
||||
for (int i = 0; i < 11; i++) {
|
||||
eleven.add(person(UUID.randomUUID(), "Walter", "Person" + i));
|
||||
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(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
||||
when(personService.findByDisplayNameContaining("Walter")).thenReturn(eleven);
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(ten));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
||||
|
||||
@@ -416,7 +435,7 @@ class NlQueryParserServiceTest {
|
||||
Person emma = person(P2, "Emma", "Raddatz");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Emma"), "receiver", null, null, List.of()));
|
||||
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma));
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||
|
||||
service.search("Briefe an Emma", PAGE);
|
||||
|
||||
@@ -437,4 +456,284 @@ class NlQueryParserServiceTest {
|
||||
|
||||
assertThat(resp.interpretation().keywordsApplied()).isTrue();
|
||||
}
|
||||
|
||||
// --- 23a. Partial-only, one candidate → ambiguous (1-item picker), search skipped ---
|
||||
|
||||
@Test
|
||||
void search_partialOnly_oneCandidate_populatesAmbiguous() {
|
||||
Person cramer = person(P1, "Clara", "Cramer");
|
||||
when(ollamaClient.parse(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", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().ambiguousPersons()).hasSize(1);
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// --- 23b. 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(ollamaClient.parse(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", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
|
||||
}
|
||||
|
||||
// --- 23c. Exactly one direct match → search executes, no picker ---
|
||||
|
||||
@Test
|
||||
void search_oneDirect_executesSearch() {
|
||||
Person clara = person(P1, "Clara", "Cram");
|
||||
when(ollamaClient.parse(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", 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");
|
||||
|
||||
// --- 24. Single keyword resolves to one tag → tag filter applied ---
|
||||
|
||||
@Test
|
||||
void search_singleKeywordResolvesToTag_appliesTagFilter() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(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", 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");
|
||||
|
||||
// --- 25. 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(ollamaClient.parse(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", 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);
|
||||
}
|
||||
|
||||
// --- 26. Keyword no tag match → stays as FTS text, resolvedTags empty ---
|
||||
|
||||
@Test
|
||||
void search_keywordNoTagMatch_staysAsFtsText() {
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost")));
|
||||
|
||||
NlSearchResponse resp = service.search("Feldpost Briefe", 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();
|
||||
}
|
||||
|
||||
// --- 27. Mixed: one keyword resolves, one doesn't → tag filter + FTS text ---
|
||||
|
||||
@Test
|
||||
void search_mixedKeywords_oneResolves_oneStaysAsText() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(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", 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();
|
||||
}
|
||||
|
||||
// --- 28. 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(ollamaClient.parse(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", 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");
|
||||
}
|
||||
|
||||
// --- 29. 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(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Thema")));
|
||||
when(tagService.findByNameContaining("Thema")).thenReturn(eleven);
|
||||
|
||||
NlSearchResponse resp = service.search("Dokumente zum Thema", 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);
|
||||
}
|
||||
|
||||
// --- 30. Short keyword (< 3 chars) → skipped, not passed to TagService ---
|
||||
|
||||
@Test
|
||||
void search_shortKeyword_skippedByTagResolution() {
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("ab", "Krieg")));
|
||||
|
||||
service.search("ab Krieg", 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");
|
||||
}
|
||||
|
||||
// --- 31. Dedup: same tag matched by two keywords → appears once ---
|
||||
|
||||
@Test
|
||||
void search_sameTagMatchedByTwoKeywords_deduplicatedToOne() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(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", 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);
|
||||
}
|
||||
|
||||
// --- 32. All keywords resolve → rawQuery fallback suppressed, text=null ---
|
||||
|
||||
@Test
|
||||
void search_allKeywordsResolveToTags_rawQueryFallbackSuppressed() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(new OllamaExtraction(List.of(), "any", null, null, List.of("Hochzeit"), "raw query text"));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit", 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");
|
||||
}
|
||||
|
||||
// --- 33. Flag independence: keywordsApplied=false AND tagsApplied=true ---
|
||||
|
||||
@Test
|
||||
void search_allKeywordsResolveToTags_keywordsAppliedFalse_tagsAppliedTrue() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(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", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().keywordsApplied()).isFalse();
|
||||
assertThat(resp.interpretation().tagsApplied()).isTrue();
|
||||
}
|
||||
|
||||
// --- 34. 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(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().resolvedTags().get(0).color()).isEqualTo("sage");
|
||||
}
|
||||
|
||||
// --- 35. Color stays null when resolveEffectiveColors leaves it unset ---
|
||||
|
||||
@Test
|
||||
void search_tagHint_colorIsNull_whenNoColorResolved() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().resolvedTags().get(0).color()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ class NlSearchControllerTest {
|
||||
PersonHint hint = new PersonHint(UUID.randomUUID(), "Walter Raddatz");
|
||||
NlQueryInterpretation interp = new NlQueryInterpretation(
|
||||
List.of(hint), List.of(), null, null,
|
||||
List.of("Krieg"), "Briefe von Walter im Krieg", true);
|
||||
List.of("Krieg"), List.of(), "Briefe von Walter im Krieg", true, false);
|
||||
return new NlSearchResponse(DocumentSearchResult.of(List.of()), interp);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ class NlSearchControllerTest {
|
||||
PersonHint b = new PersonHint(UUID.randomUUID(), "Walter Schmidt");
|
||||
NlQueryInterpretation interp = new NlQueryInterpretation(
|
||||
List.of(), List.of(a, b), null, null,
|
||||
List.of(), "Briefe von Walter", false);
|
||||
List.of(), List.of(), "Briefe von Walter", false, false);
|
||||
NlSearchResponse resp = new NlSearchResponse(DocumentSearchResult.of(List.of()), interp);
|
||||
when(nlQueryParserService.search(anyString(), any())).thenReturn(resp);
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -666,4 +666,17 @@ class TagServiceTest {
|
||||
// verify findAllById was called at least twice: once for extras, once inside resolveEffectiveColors
|
||||
verify(tagRepository, atLeastOnce()).findAllById(any());
|
||||
}
|
||||
|
||||
// ─── findByNameContaining ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findByNameContaining_delegatesToRepository() {
|
||||
Tag krieg = Tag.builder().id(UUID.randomUUID()).name("Krieg").build();
|
||||
when(tagRepository.findByNameContainingIgnoreCase("krieg")).thenReturn(List.of(krieg));
|
||||
|
||||
List<Tag> result = tagService.findByNameContaining("krieg");
|
||||
|
||||
assertThat(result).containsExactly(krieg);
|
||||
verify(tagRepository).findByNameContainingIgnoreCase("krieg");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,10 +171,18 @@ _See also [Chronik](#chronik-internal)._
|
||||
|
||||
**NlSearch** — the natural-language document search feature. Users type a plain-German query (e.g. "Was hat Walter im Krieg an Emma geschrieben?"); the backend parses it via Ollama, resolves person names to database UUIDs, and delegates to the standard `DocumentService.searchDocuments()` path. Endpoint: `POST /api/search/nl`.
|
||||
|
||||
**NlQueryInterpretation** — the structured result of parsing a natural-language query. Contains: `resolvedPersons` (persons whose names unambiguously matched one DB record), `ambiguousPersons` (all candidates when a name matched more than one person), `keywords` (LLM-extracted search terms), `dateFrom`/`dateTo` (extracted date range), `rawQuery` (the original user input), and `keywordsApplied` (whether keyword FTS was used in the search).
|
||||
**NlQueryInterpretation** — the structured result of parsing a natural-language query. Contains: `resolvedPersons` (persons whose names unambiguously matched one DB record), `ambiguousPersons` (all candidates when a name matched more than one person), `keywords` (LLM-extracted search terms), `dateFrom`/`dateTo` (extracted date range), `rawQuery` (the original user input), `keywordsApplied` (whether keyword FTS was used), `resolvedTags` (tags matched by keyword→tag resolution), and `tagsApplied` (whether the OR-union tag filter was applied).
|
||||
|
||||
**keyword→tag resolution** — the post-Ollama step in `NlQueryParserService` where each LLM-extracted keyword is substring-matched against the tag taxonomy via `TagService.findByNameContaining()`. Keywords that hit one or more tags are removed from the FTS text list and become an OR-union tag filter; keywords with no match remain as FTS text. Matching is case-insensitive and traverses the tag hierarchy via the recursive CTE `findDescendantIdsByName`. See ADR-033.
|
||||
|
||||
**PersonHint** — a lightweight `{id, displayName}` pair used in `NlQueryInterpretation` to describe a resolved or ambiguous person without exposing the full `Person` entity to the frontend.
|
||||
|
||||
**NameMatches** — the Person-domain result of `PersonService.resolveByName(name)`: candidate persons split by name-match strength into `direct` and `partial`. A match is **direct** when every query token is a whole-token match (order-independent, alias/maiden-name aware) across all of a person's name components (`firstName`, `lastName`, `alias`, each `PersonNameAlias` first+last, `title`); a **partial** matched the substring fetch but is not direct (e.g. "Cram" → "Clara Cramer"). The vocabulary is deliberately match strength, not the search layer's resolved/ambiguous buckets — `NlQueryParserService` maps one direct → resolved (auto-select), ≥2 direct → ambiguous, partial-only → ambiguous suggestions ("Meintest du …?"), and no candidates → folded into full-text search.
|
||||
|
||||
**TagHint** — a lightweight `{id, name, color?}` triple used in `NlQueryInterpretation.resolvedTags` to describe a tag matched by keyword→tag resolution. `color` is the tag's effective color (one-level inheritance from parent when the tag has no own color), or null if neither tag nor parent has a color.
|
||||
|
||||
**theme chip** `[frontend]` — a removable chip rendered in `InterpretationChipRow` for each entry in `NlQueryInterpretation.resolvedTags` when `tagsApplied` is `true`. Displays "Thema: {tag.name}" (prefix varies by locale). Clicking × removes the tag from the OR-union filter and navigates to `/documents?tag=…&tagOp=OR` with remaining tag and person parameters preserved.
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure Terms
|
||||
|
||||
@@ -26,7 +26,7 @@ Family members write their search intent in plain German ("Was hat Walter im Kri
|
||||
|
||||
**DB-blind name resolution:** The Ollama prompt stays small (the raw query only); person database records are never sent to the model. Name resolution happens as a cheap SQL query after the model returns. This keeps the prompt short, avoids data leakage, and means adding 1,000 new persons requires no prompt change.
|
||||
|
||||
**Graceful degradation:** `RestClientOllamaClient.isHealthy()` is called inline before each inference request (calls `GET /api/tags` on a 2-second connect-timeout client). If Ollama is absent or times out, `NlQueryParserService` throws `DomainException` with `SMART_SEARCH_UNAVAILABLE` (HTTP 503). The regular structured search (`GET /api/documents/search`) is unaffected — it never calls Ollama.
|
||||
**Graceful degradation:** In-path Ollama failures surface via `OllamaClient.parse()` — any `IOException`, read timeout, or non-2xx response is caught by `RestClientOllamaClient` and re-thrown as `DomainException(SMART_SEARCH_UNAVAILABLE, HTTP 503)`. `isHealthy()` has no callers inside `search/`; it is reserved for the ops/health-endpoint polling path only (e.g. a future `/api/health/ollama` endpoint). The regular structured search (`GET /api/documents/search`) is unaffected — it never calls Ollama.
|
||||
|
||||
**Expected inference latency:** 2–15 seconds on the current CPU-only hardware. The frontend issue must show a persistent "Suche läuft…" indicator for the full duration (see `aria-live="polite"` requirement in issue #738 frontend notes). The backend timeout is 30 seconds (`app.ollama.timeout-seconds=30`) — chosen as a safe upper bound for Q4_K_M on the i7-6700 with a realistic 500-character query under modest concurrent load.
|
||||
|
||||
@@ -44,6 +44,8 @@ Family members write their search intent in plain German ("Was hat Walter im Kri
|
||||
|
||||
**`search/` → `person/` + `document/` dependency direction:** `NlQueryParserService` calls `PersonService.findByDisplayNameContaining()` and `DocumentService.searchDocuments()` — both are legitimate cross-domain service calls, not repository leaks. The `search/` package has no JPA entities of its own and never accesses `PersonRepository` or `DocumentRepository` directly.
|
||||
|
||||
**Keyword→tag resolution** (issue #743): After Ollama extracts the `keywords` list, `NlQueryParserService` calls `TagService.findByNameContaining()` for each keyword. Keywords that match one or more tags are removed from the FTS text list and added as OR-union tag filters; keywords with no tag match remain as FTS text. Resolved tags are returned to the frontend as `TagHint` objects in `NlQueryInterpretation.resolvedTags` and rendered as removable "Thema: X" chips. The `tagsApplied` flag signals whether the OR-union filter was actually passed to `DocumentService.searchDocuments()` — it is `false` when the `personRole:any` single-person path is taken, because that path has no tag filter slot. See ADR-033 for the tag name resolution and case-collision rules that `TagService.findByNameContaining()` relies on.
|
||||
|
||||
## Decision
|
||||
|
||||
**Introduce a new `search/` domain package** with a local Ollama integration via `RestClientOllamaClient`. The Ollama service runs as a separate Docker container, reachable only on the internal Docker network (`expose: ["11434"]`, not `ports:`). The backend calls Ollama's `/api/generate` endpoint with grammar-constrained JSON output. Name resolution and document search are performed by existing services after the model returns.
|
||||
|
||||
@@ -10,7 +10,7 @@ Container(ollama, "Ollama", "ollama/ollama — port 11434 (internal only)")
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(nlCtrl, "NlSearchController", "Spring MVC — POST /api/search/nl", "REST entry point for natural language search. Enforces READ_ALL permission. Uses @AuthenticationPrincipal UserDetails to obtain the caller's email for rate limiting. Delegates to NlQueryParserService and returns NlSearchResponse.")
|
||||
Component(rateLimiter, "NlSearchRateLimiter", "Spring Service", "Bucket4j + Caffeine LoadingCache keyed on user email. Allows 5 NL search requests per minute per user. Throws DomainException(SMART_SEARCH_RATE_LIMITED / HTTP 429) when the bucket is exhausted. Node-local — same caveat as LoginRateLimiter.")
|
||||
Component(parserSvc, "NlQueryParserService", "Spring Service", "Orchestrates the full NL search pipeline: (1) validates query length, (2) calls OllamaClient.parse() to extract structured intent, (3) resolves each person name via PersonService.findByDisplayNameContaining(), (4) applies multi-name / personRole heuristics, (5) delegates to DocumentService.searchDocuments() or searchDocumentsByPersonId(). Returns NlSearchResponse. Never logs raw query content (PII).")
|
||||
Component(parserSvc, "NlQueryParserService", "Spring Service", "Orchestrates the full NL search pipeline: (1) validates query length, (2) calls OllamaClient.parse() to extract structured intent, (3) resolves keywords to tags via TagService.findByNameContaining(), (4) resolves each person name via PersonService.findByDisplayNameContaining(), (5) applies multi-name / personRole heuristics, (6) delegates to DocumentService.searchDocuments() or searchDocumentsByPersonId(). Returns NlSearchResponse. Never logs raw query content (PII).")
|
||||
Component(ollamaClient, "RestClientOllamaClient", "Spring Service — implements OllamaClient + OllamaHealthClient", "HTTP client for the Ollama API. Uses two separate RestClient instances: inference client (30 s read timeout) and health-check client (2 s connect timeout). Calls POST /api/generate with grammar-constrained JSON schema (personNames, personRole, dateFrom, dateTo, keywords). isHealthy() polls GET /api/tags. Null-coalesces absent personNames/keywords to List.of(). Defaults unknown personRole to 'any' with a warning log. Maps timeout/5xx/parse errors to DomainException(SMART_SEARCH_UNAVAILABLE / HTTP 503).")
|
||||
Component(ollamaProps, "OllamaProperties", "@ConfigurationProperties(\"app.ollama\")", "Config bean: baseUrl, model (qwen2.5:7b-instruct-q4_K_M), timeoutSeconds (default: 30), healthCheckTimeoutSeconds (default: 2).")
|
||||
Component(rateLimitProps, "NlSearchRateLimitProperties", "@ConfigurationProperties(\"app.nl-search.rate-limit\")", "Config bean: maxRequestsPerMinute (default: 5).")
|
||||
@@ -18,6 +18,7 @@ System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
|
||||
Component(personSvc, "PersonService", "Spring Service", "See diagram 3e. findByDisplayNameContaining(fragment) delegates to PersonRepository.searchByName() — covers first+last name, alias, and name aliases via LEFT JOIN.")
|
||||
Component(documentSvc, "DocumentService", "Spring Service", "See diagram 3b. searchDocuments() for keyword/sender/receiver/date queries. searchDocumentsByPersonId() for OR-semantics single-person queries (person as sender OR receiver, no keyword filter).")
|
||||
Component(tagSvc, "TagService", "Spring Service", "See diagram 3b. findByNameContaining(fragment) delegates to TagRepository.findByNameContainingIgnoreCase(). resolveEffectiveColors() applies one-level color inheritance in-place on a collection of Tag entities.")
|
||||
|
||||
Rel(frontend, nlCtrl, "POST /api/search/nl with JSON query", "HTTP / JSON")
|
||||
Rel(nlCtrl, rateLimiter, "checkAndConsume(userEmail)")
|
||||
@@ -25,9 +26,12 @@ Rel(nlCtrl, parserSvc, "parse(query)")
|
||||
Rel(parserSvc, ollamaClient, "parse(rawQuery) — extracts intent", "HTTP / JSON")
|
||||
Rel(ollamaClient, ollama, "POST /api/generate (grammar-constrained JSON schema)", "HTTP / REST")
|
||||
Rel(ollamaClient, ollama, "GET /api/tags (health check)", "HTTP / REST")
|
||||
Rel(parserSvc, tagSvc, "findByNameContaining(keyword) — keyword→tag resolution")
|
||||
Rel(parserSvc, tagSvc, "resolveEffectiveColors(tags)")
|
||||
Rel(parserSvc, personSvc, "findByDisplayNameContaining(name) for each extracted name")
|
||||
Rel(parserSvc, documentSvc, "searchDocuments() or searchDocumentsByPersonId()")
|
||||
Rel(documentSvc, db, "JPA queries", "JDBC")
|
||||
Rel(personSvc, db, "JPA queries", "JDBC")
|
||||
Rel(tagSvc, db, "JPA queries", "JDBC")
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -13,8 +13,10 @@ const interpretation = {
|
||||
dateFrom: '1914-01-01',
|
||||
dateTo: '1918-12-31',
|
||||
keywords: ['krieg'],
|
||||
resolvedTags: [{ id: '33333333-3333-3333-3333-333333333333', name: 'Weltkrieg', color: 'sage' }],
|
||||
rawQuery: 'Was hat Walter an Emma im Krieg geschrieben?',
|
||||
keywordsApplied: true
|
||||
keywordsApplied: true,
|
||||
tagsApplied: true
|
||||
};
|
||||
|
||||
const nlResponse = {
|
||||
@@ -56,9 +58,10 @@ test.describe('NL (smart) search — happy path', () => {
|
||||
// Loading panel announced to screen readers.
|
||||
await expect(page.getByText(/Archiv wird befragt/)).toBeVisible();
|
||||
|
||||
// Directional chip (Walter → Emma) + keyword chip render once the fixture resolves.
|
||||
// Directional chip (Walter → Emma) + keyword chip + theme chip render once the fixture resolves.
|
||||
await expect(page.getByText('→')).toBeVisible();
|
||||
await expect(page.getByText('Stichwort: krieg')).toBeVisible();
|
||||
await expect(page.getByText(/Thema:.*Weltkrieg/)).toBeVisible();
|
||||
|
||||
// Accessibility — light mode.
|
||||
const lightScan = await new AxeBuilder({ page })
|
||||
@@ -80,4 +83,31 @@ test.describe('NL (smart) search — happy path', () => {
|
||||
await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/);
|
||||
await expect(page).toHaveURL(/receiverId=22222222-2222-2222-2222-222222222222/);
|
||||
});
|
||||
|
||||
test('removing the last theme chip drops tag/tagOp but keeps person params', async ({ page }) => {
|
||||
await page.route('**/api/search/nl', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(nlResponse)
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/documents');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByRole('button', { name: /Text/ }).click();
|
||||
|
||||
const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…');
|
||||
await input.fill('Was hat Walter an Emma im Krieg geschrieben?');
|
||||
await input.press('Enter');
|
||||
|
||||
await expect(page.getByText(/Thema:.*Weltkrieg/)).toBeVisible();
|
||||
|
||||
// Remove the single theme chip — URL must carry sender UUID but no tag/tagOp.
|
||||
await page.getByRole('button', { name: 'Filter entfernen: Thema: Weltkrieg' }).click();
|
||||
await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/);
|
||||
const url = page.url();
|
||||
expect(url).not.toMatch(/tag=/);
|
||||
expect(url).not.toMatch(/tagOp=/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,10 +42,12 @@
|
||||
"search_chip_sender": "Absender",
|
||||
"search_chip_date": "Zeitraum",
|
||||
"search_chip_keyword": "Stichwort",
|
||||
"search_chip_theme_prefix": "Thema",
|
||||
"search_chip_directional_label": "Von {from} zu {to}, Filter entfernen",
|
||||
"search_disambiguation_trigger_label": "Mehrere Personen gefunden — zum Auswählen klicken",
|
||||
"search_disambiguation_cue": "(auswählen…)",
|
||||
"search_disambiguation_heading": "Person auswählen",
|
||||
"search_disambiguation_did_you_mean": "Meintest du {name}?",
|
||||
"search_disambiguation_select_label": "{name} auswählen",
|
||||
"error_validation_error": "Die Eingabe ist ungültig.",
|
||||
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||
|
||||
@@ -42,10 +42,12 @@
|
||||
"search_chip_sender": "Sender",
|
||||
"search_chip_date": "Period",
|
||||
"search_chip_keyword": "Keyword",
|
||||
"search_chip_theme_prefix": "Topic",
|
||||
"search_chip_directional_label": "From {from} to {to}, remove filter",
|
||||
"search_disambiguation_trigger_label": "Several people found — click to choose",
|
||||
"search_disambiguation_cue": "(choose…)",
|
||||
"search_disambiguation_heading": "Choose a person",
|
||||
"search_disambiguation_did_you_mean": "Did you mean {name}?",
|
||||
"search_disambiguation_select_label": "Select {name}",
|
||||
"error_validation_error": "The input is invalid.",
|
||||
"error_internal_error": "An unexpected error occurred.",
|
||||
|
||||
@@ -42,10 +42,12 @@
|
||||
"search_chip_sender": "Remitente",
|
||||
"search_chip_date": "Período",
|
||||
"search_chip_keyword": "Palabra clave",
|
||||
"search_chip_theme_prefix": "Tema",
|
||||
"search_chip_directional_label": "De {from} a {to}, eliminar filtro",
|
||||
"search_disambiguation_trigger_label": "Se encontraron varias personas — haga clic para elegir",
|
||||
"search_disambiguation_cue": "(elegir…)",
|
||||
"search_disambiguation_heading": "Elegir una persona",
|
||||
"search_disambiguation_did_you_mean": "¿Quería decir {name}?",
|
||||
"search_disambiguation_select_label": "Seleccionar {name}",
|
||||
"error_validation_error": "La entrada no es válida.",
|
||||
"error_internal_error": "Se ha producido un error inesperado.",
|
||||
|
||||
@@ -1905,8 +1905,10 @@ export interface components {
|
||||
/** Format: date */
|
||||
dateTo?: string;
|
||||
keywords: string[];
|
||||
resolvedTags: components["schemas"]["TagHint"][];
|
||||
rawQuery: string;
|
||||
keywordsApplied: boolean;
|
||||
tagsApplied: boolean;
|
||||
};
|
||||
NlSearchResponse: {
|
||||
result: components["schemas"]["DocumentSearchResult"];
|
||||
@@ -1917,6 +1919,12 @@ export interface components {
|
||||
id: string;
|
||||
displayName: string;
|
||||
};
|
||||
TagHint: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
};
|
||||
SearchMatchData: {
|
||||
transcriptionSnippet?: string;
|
||||
titleOffsets: components["schemas"]["MatchOffset"][];
|
||||
|
||||
@@ -10,6 +10,8 @@ import BulkSelectionBar from '$lib/document/BulkSelectionBar.svelte';
|
||||
import TimelineDensityFilter from '$lib/document/TimelineDensityFilter.svelte';
|
||||
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';
|
||||
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
|
||||
import { getErrorMessage, parseBackendError } from '$lib/shared/errors';
|
||||
@@ -55,7 +57,16 @@ let nlResult = $state<DocumentSearchResult | null>(null);
|
||||
|
||||
const showNlView = $derived(smartMode && nlSubmitted);
|
||||
const nlHasResults = $derived((nlResult?.items.length ?? 0) > 0);
|
||||
const nlIsAmbiguous = $derived((nlInterpretation?.ambiguousPersons.length ?? 0) > 0);
|
||||
const ambiguousPersons = $derived(nlInterpretation?.ambiguousPersons ?? []);
|
||||
const nlIsAmbiguous = $derived(ambiguousPersons.length > 0);
|
||||
// A 1-item picker is always a "did you mean …?" suggestion (a single direct match auto-selects
|
||||
// and never reaches the picker); ≥2 keeps the "choose a person" framing and the action cue.
|
||||
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);
|
||||
|
||||
function hasAdvancedFilters() {
|
||||
return (
|
||||
@@ -269,8 +280,6 @@ function paramsFromInterpretation(interp: NlQueryInterpretation) {
|
||||
};
|
||||
}
|
||||
|
||||
type ChipType = 'sender' | 'directional' | 'date' | 'keyword';
|
||||
|
||||
function removeChip(type: ChipType, value?: string) {
|
||||
if (!nlInterpretation) return;
|
||||
const p = paramsFromInterpretation(nlInterpretation);
|
||||
@@ -285,6 +294,11 @@ function removeChip(type: ChipType, value?: string) {
|
||||
} else if (type === 'keyword' && value) {
|
||||
const remaining = nlInterpretation.keywords.filter((keyword) => keyword !== value);
|
||||
p.q = remaining.join(' ');
|
||||
} else if (type === 'theme' && value) {
|
||||
const url = buildThemeRemovalUrl(nlInterpretation, value);
|
||||
resetNlState();
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
return;
|
||||
}
|
||||
applyResolvedAndSearch(p);
|
||||
}
|
||||
@@ -437,6 +451,8 @@ $effect(() => {
|
||||
{#if nlIsAmbiguous}
|
||||
<DisambiguationPicker
|
||||
persons={nlInterpretation.ambiguousPersons}
|
||||
heading={disambiguationHeading}
|
||||
showCue={showDisambiguationCue}
|
||||
onSelect={selectDisambiguated}
|
||||
/>
|
||||
{:else}
|
||||
|
||||
85
frontend/src/routes/documents/theme-chip-removal.spec.ts
Normal file
85
frontend/src/routes/documents/theme-chip-removal.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildThemeRemovalUrl } from './theme-chip-removal.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||
|
||||
function makeInterp(overrides: Partial<NlQueryInterpretation> = {}): NlQueryInterpretation {
|
||||
return {
|
||||
resolvedPersons: [],
|
||||
ambiguousPersons: [],
|
||||
keywords: [],
|
||||
resolvedTags: [],
|
||||
rawQuery: '',
|
||||
keywordsApplied: false,
|
||||
tagsApplied: true,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function makeTag(id: string, name: string, color?: string) {
|
||||
return color ? { id, name, color } : { id, name };
|
||||
}
|
||||
|
||||
describe('buildThemeRemovalUrl', () => {
|
||||
it('N remaining tags → N tag params + tagOp=OR', () => {
|
||||
const interp = makeInterp({
|
||||
resolvedTags: [
|
||||
makeTag('aaa', 'Hochzeit'),
|
||||
makeTag('bbb', 'Weltkrieg'),
|
||||
makeTag('ccc', 'Familie')
|
||||
]
|
||||
});
|
||||
const url = buildThemeRemovalUrl(interp, 'Hochzeit');
|
||||
const params = new URL(url, 'http://x').searchParams;
|
||||
expect(params.getAll('tag')).toEqual(['Weltkrieg', 'Familie']);
|
||||
expect(params.get('tagOp')).toBe('OR');
|
||||
});
|
||||
|
||||
it('last tag removed → no tag or tagOp params in URL', () => {
|
||||
const interp = makeInterp({
|
||||
resolvedTags: [makeTag('aaa', 'Hochzeit')]
|
||||
});
|
||||
const url = buildThemeRemovalUrl(interp, 'Hochzeit');
|
||||
const params = new URL(url, 'http://x').searchParams;
|
||||
expect(params.getAll('tag')).toEqual([]);
|
||||
expect(params.get('tagOp')).toBeNull();
|
||||
});
|
||||
|
||||
it('last tag removed with resolved sender person → sender param intact', () => {
|
||||
const interp = makeInterp({
|
||||
resolvedPersons: [{ id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter' }],
|
||||
resolvedTags: [makeTag('aaa', 'Hochzeit')]
|
||||
});
|
||||
const url = buildThemeRemovalUrl(interp, 'Hochzeit');
|
||||
const params = new URL(url, 'http://x').searchParams;
|
||||
expect(params.get('senderId')).toBe('11111111-1111-1111-1111-111111111111');
|
||||
expect(params.getAll('tag')).toEqual([]);
|
||||
expect(params.get('tagOp')).toBeNull();
|
||||
});
|
||||
|
||||
it('null-color tag → tag name emitted correctly; color does not affect params', () => {
|
||||
const interp = makeInterp({
|
||||
resolvedTags: [makeTag('aaa', 'Erbschaft'), makeTag('bbb', 'Migration')]
|
||||
});
|
||||
const url = buildThemeRemovalUrl(interp, 'Erbschaft');
|
||||
const params = new URL(url, 'http://x').searchParams;
|
||||
expect(params.getAll('tag')).toEqual(['Migration']);
|
||||
expect(params.get('tagOp')).toBe('OR');
|
||||
});
|
||||
|
||||
it('directional pair → senderId and receiverId both emitted', () => {
|
||||
const interp = makeInterp({
|
||||
resolvedPersons: [
|
||||
{ id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter' },
|
||||
{ id: '22222222-2222-2222-2222-222222222222', displayName: 'Emma' }
|
||||
],
|
||||
resolvedTags: [makeTag('aaa', 'Krieg'), makeTag('bbb', 'Heimat')]
|
||||
});
|
||||
const url = buildThemeRemovalUrl(interp, 'Krieg');
|
||||
const params = new URL(url, 'http://x').searchParams;
|
||||
expect(params.get('senderId')).toBe('11111111-1111-1111-1111-111111111111');
|
||||
expect(params.get('receiverId')).toBe('22222222-2222-2222-2222-222222222222');
|
||||
expect(params.getAll('tag')).toEqual(['Heimat']);
|
||||
});
|
||||
});
|
||||
26
frontend/src/routes/documents/theme-chip-removal.ts
Normal file
26
frontend/src/routes/documents/theme-chip-removal.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||
|
||||
export function buildThemeRemovalUrl(
|
||||
interp: NlQueryInterpretation,
|
||||
removedTagName: string
|
||||
): string {
|
||||
const remaining = interp.resolvedTags.filter((t) => t.name !== removedTagName);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const resolved = interp.resolvedPersons;
|
||||
if (resolved.length >= 1) params.set('senderId', resolved[0].id);
|
||||
if (resolved.length >= 2) params.set('receiverId', resolved[1].id);
|
||||
if (interp.dateFrom) params.set('from', interp.dateFrom);
|
||||
if (interp.dateTo) params.set('to', interp.dateTo);
|
||||
if (interp.keywordsApplied && interp.keywords.length > 0) {
|
||||
params.set('q', interp.keywords.join(' '));
|
||||
}
|
||||
|
||||
remaining.forEach((tag) => params.append('tag', tag.name));
|
||||
if (remaining.length > 0) params.set('tagOp', 'OR');
|
||||
|
||||
const qs = params.toString();
|
||||
return qs ? `/documents?${qs}` : '/documents';
|
||||
}
|
||||
@@ -6,15 +6,28 @@ import type { components } from '$lib/generated/api';
|
||||
|
||||
type PersonHint = components['schemas']['PersonHint'];
|
||||
|
||||
let { persons, onSelect }: { persons: PersonHint[]; onSelect: (person: PersonHint) => void } =
|
||||
$props();
|
||||
let {
|
||||
persons,
|
||||
heading,
|
||||
showCue,
|
||||
onSelect
|
||||
}: {
|
||||
persons: PersonHint[];
|
||||
heading: string;
|
||||
showCue: boolean;
|
||||
onSelect: (person: PersonHint) => void;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let triggerEl = $state<HTMLButtonElement>();
|
||||
let listEl = $state<HTMLUListElement>();
|
||||
|
||||
const panelId = 'disambiguation-panel';
|
||||
const headingId = 'disambiguation-heading';
|
||||
const names = $derived(persons.map((person) => person.displayName).join(', '));
|
||||
const triggerLabel = $derived(
|
||||
persons.length === 1 ? heading : m.search_disambiguation_trigger_label()
|
||||
);
|
||||
|
||||
async function openPicker() {
|
||||
open = true;
|
||||
@@ -54,33 +67,36 @@ function onKeydown(event: KeyboardEvent) {
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
aria-label={m.search_disambiguation_trigger_label()}
|
||||
aria-label={triggerLabel}
|
||||
onclick={toggle}
|
||||
class="inline-flex min-h-[44px] items-center gap-1.5 rounded-full border border-line bg-muted px-3 text-sm text-ink-2 outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
<span class="max-w-[8rem] truncate sm:max-w-[12rem]">{names}</span>
|
||||
<span class="text-ink-3">{m.search_disambiguation_cue()}</span>
|
||||
{#if showCue}
|
||||
<span class="text-ink-3">{m.search_disambiguation_cue()}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<ul
|
||||
bind:this={listEl}
|
||||
<div
|
||||
id={panelId}
|
||||
aria-label={m.search_disambiguation_heading()}
|
||||
class="absolute left-0 z-10 mt-1 min-w-[12rem] rounded-sm border border-line bg-surface py-1 shadow-md"
|
||||
>
|
||||
{#each persons as person (person.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.search_disambiguation_select_label({ name: person.displayName })}
|
||||
onclick={() => select(person)}
|
||||
class="flex min-h-[44px] w-full items-center px-4 text-left text-sm text-ink outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
{person.displayName}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p id={headingId} class="px-4 py-1.5 text-sm font-bold text-ink">{heading}</p>
|
||||
<ul bind:this={listEl} aria-labelledby={headingId}>
|
||||
{#each persons as person (person.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.search_disambiguation_select_label({ name: person.displayName })}
|
||||
onclick={() => select(person)}
|
||||
class="flex min-h-[44px] w-full items-center px-4 text-left text-sm text-ink outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
{person.displayName}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,8 @@ const persons: PersonHint[] = [
|
||||
{ id: 'w2', displayName: 'Walter Müller' }
|
||||
];
|
||||
|
||||
const multiProps = { persons, heading: 'Person auswählen', showCue: true };
|
||||
|
||||
function pressEscape() {
|
||||
(document.activeElement as HTMLElement).dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
|
||||
@@ -21,7 +23,7 @@ function pressEscape() {
|
||||
|
||||
describe('DisambiguationPicker', () => {
|
||||
it('opens the picker and shows a select option per ambiguous person', async () => {
|
||||
render(DisambiguationPicker, { persons, onSelect: vi.fn() });
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
|
||||
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
||||
@@ -32,7 +34,7 @@ describe('DisambiguationPicker', () => {
|
||||
});
|
||||
|
||||
it('moves focus into the picker list on open', async () => {
|
||||
render(DisambiguationPicker, { persons, onSelect: vi.fn() });
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
|
||||
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
||||
@@ -40,7 +42,7 @@ describe('DisambiguationPicker', () => {
|
||||
});
|
||||
|
||||
it('returns focus to the trigger when closed with Escape', async () => {
|
||||
render(DisambiguationPicker, { persons, onSelect: vi.fn() });
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
|
||||
const trigger = page.getByRole('button', { name: /Mehrere Personen gefunden/ });
|
||||
await trigger.click();
|
||||
await expect
|
||||
@@ -52,7 +54,7 @@ describe('DisambiguationPicker', () => {
|
||||
|
||||
it('does not call onSelect when dismissed without choosing', async () => {
|
||||
const onSelect = vi.fn();
|
||||
render(DisambiguationPicker, { persons, onSelect });
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect });
|
||||
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
||||
@@ -63,9 +65,54 @@ describe('DisambiguationPicker', () => {
|
||||
|
||||
it('calls onSelect with the chosen person', async () => {
|
||||
const onSelect = vi.fn();
|
||||
render(DisambiguationPicker, { persons, onSelect });
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect });
|
||||
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||
await page.getByRole('button', { name: 'Walter Müller auswählen' }).click();
|
||||
expect(onSelect).toHaveBeenCalledWith(persons[1]);
|
||||
});
|
||||
|
||||
it('renders the supplied heading as a visible panel heading', async () => {
|
||||
render(DisambiguationPicker, {
|
||||
persons: [{ id: 'c1', displayName: 'Clara Cramer' }],
|
||||
heading: 'Meintest du Clara Cramer?',
|
||||
showCue: false,
|
||||
onSelect: vi.fn()
|
||||
});
|
||||
await page.getByRole('button', { name: 'Meintest du Clara Cramer?' }).click();
|
||||
await expect.element(page.getByText('Meintest du Clara Cramer?')).toBeVisible();
|
||||
});
|
||||
|
||||
it('suppresses the cue when showCue is false', async () => {
|
||||
render(DisambiguationPicker, {
|
||||
persons: [{ id: 'c1', displayName: 'Clara Cramer' }],
|
||||
heading: 'Meintest du Clara Cramer?',
|
||||
showCue: false,
|
||||
onSelect: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText('(auswählen…)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the cue when showCue is true', async () => {
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
|
||||
await expect.element(page.getByText('(auswählen…)')).toBeVisible();
|
||||
});
|
||||
|
||||
it('announces the did-you-mean heading as the trigger accessible name for a single suggestion', async () => {
|
||||
render(DisambiguationPicker, {
|
||||
persons: [{ id: 'c1', displayName: 'Clara Cramer' }],
|
||||
heading: 'Meintest du Clara Cramer?',
|
||||
showCue: false,
|
||||
onSelect: vi.fn()
|
||||
});
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Meintest du Clara Cramer?' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the multiple-people trigger accessible name for two or more suggestions', async () => {
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Mehrere Personen gefunden/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,10 @@ import { SvelteSet } from 'svelte/reactivity';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
import type { ChipType } from './chip-types.js';
|
||||
|
||||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||
type ChipType = 'sender' | 'directional' | 'date' | 'keyword';
|
||||
type TagHint = components['schemas']['TagHint'];
|
||||
|
||||
let {
|
||||
interpretation,
|
||||
@@ -18,7 +20,8 @@ type Chip =
|
||||
| { key: string; type: 'sender'; label: string }
|
||||
| { key: string; type: 'directional'; from: string; to: string }
|
||||
| { key: string; type: 'date'; label: string }
|
||||
| { key: string; type: 'keyword'; value: string; label: string };
|
||||
| { key: string; type: 'keyword'; value: string; label: string }
|
||||
| { key: string; type: 'theme'; tag: TagHint; label: string };
|
||||
|
||||
// Locally removed chips. The parent remounts this component (via {#key}) on every
|
||||
// new NL search, so this set never needs an explicit reset.
|
||||
@@ -35,9 +38,22 @@ function dateRangeLabel(from: string | undefined, to: string | undefined): strin
|
||||
return fromYear ?? toYear ?? '';
|
||||
}
|
||||
|
||||
function tagColorStyle(color: string | undefined): string | undefined {
|
||||
if (!color) return undefined;
|
||||
return `background-color: var(--c-tag-${color}); border-left-color: var(--c-tag-${color})`;
|
||||
}
|
||||
|
||||
const chips = $derived.by(() => {
|
||||
const list: Chip[] = [];
|
||||
const { resolvedPersons, dateFrom, dateTo, keywords, keywordsApplied } = interpretation;
|
||||
const {
|
||||
resolvedPersons,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
keywords,
|
||||
keywordsApplied,
|
||||
resolvedTags,
|
||||
tagsApplied
|
||||
} = interpretation;
|
||||
|
||||
if (resolvedPersons.length >= 2) {
|
||||
list.push({
|
||||
@@ -73,6 +89,17 @@ const chips = $derived.by(() => {
|
||||
}
|
||||
}
|
||||
|
||||
if (tagsApplied) {
|
||||
for (const tag of resolvedTags) {
|
||||
list.push({
|
||||
key: 'theme:' + tag.id,
|
||||
type: 'theme',
|
||||
tag,
|
||||
label: `${m.search_chip_theme_prefix()}: ${tag.name}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return list.filter((chip) => !removed.has(chip.key));
|
||||
});
|
||||
|
||||
@@ -82,7 +109,13 @@ const showKeywordsNotApplied = $derived(
|
||||
|
||||
function remove(chip: Chip) {
|
||||
removed.add(chip.key);
|
||||
onRemoveChip(chip.type, chip.type === 'keyword' ? chip.value : undefined);
|
||||
if (chip.type === 'keyword') {
|
||||
onRemoveChip(chip.type, chip.value);
|
||||
} else if (chip.type === 'theme') {
|
||||
onRemoveChip(chip.type, chip.tag.name);
|
||||
} else {
|
||||
onRemoveChip(chip.type, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const nameSpan = 'sm:max-w-[12rem] max-w-[8rem] truncate';
|
||||
@@ -112,6 +145,21 @@ const removeButton =
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</span>
|
||||
{:else if chip.type === 'theme'}
|
||||
<span data-chip-type="theme" class={chipWrapper} style={tagColorStyle(chip.tag.color)}>
|
||||
<span>{m.search_chip_theme_prefix()}:</span>
|
||||
<span class={nameSpan}>{chip.tag.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
class={removeButton}
|
||||
aria-label={m.search_filter_remove_label({
|
||||
label: `${m.search_chip_theme_prefix()}: ${chip.tag.name}`
|
||||
})}
|
||||
onclick={() => remove(chip)}
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</span>
|
||||
{:else}
|
||||
<span data-chip-type={chip.type} class={chipWrapper}>
|
||||
<span class={nameSpan}>{chip.label}</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { components } from '$lib/generated/api';
|
||||
|
||||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||
type PersonHint = components['schemas']['PersonHint'];
|
||||
type TagHint = components['schemas']['TagHint'];
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
@@ -17,8 +18,10 @@ const makeInterpretation = (
|
||||
resolvedPersons: [],
|
||||
ambiguousPersons: [],
|
||||
keywords: [],
|
||||
resolvedTags: [],
|
||||
rawQuery: 'test',
|
||||
keywordsApplied: true,
|
||||
tagsApplied: false,
|
||||
...overrides
|
||||
});
|
||||
|
||||
@@ -130,4 +133,79 @@ describe('InterpretationChipRow', () => {
|
||||
.element(page.getByRole('button', { name: new RegExp('Absender') }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── theme chips ─────────────────────────────────────────────────────────────
|
||||
|
||||
const makeTag = (id: string, name: string, color?: string): TagHint => ({ id, name, color });
|
||||
|
||||
it('renders theme chips when tagsApplied is true', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||||
tagsApplied: true
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(1);
|
||||
await expect.element(page.getByText(/Thema: Hochzeit/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no theme chips when tagsApplied is false', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||||
tagsApplied: false
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders exactly N theme chips for N resolved tags', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Krieg'), makeTag('t2', 'Hochzeit'), makeTag('t3', 'Familie')],
|
||||
tagsApplied: true
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('calls onRemoveChip with "theme" and tag name when × is clicked', async () => {
|
||||
const onRemoveChip = vi.fn();
|
||||
render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||||
tagsApplied: true
|
||||
}),
|
||||
onRemoveChip
|
||||
});
|
||||
await page.getByRole('button', { name: /Thema: Hochzeit/ }).click();
|
||||
expect(onRemoveChip).toHaveBeenCalledWith('theme', 'Hochzeit');
|
||||
});
|
||||
|
||||
it('applies inline color style for a tag with a color', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Hochzeit', 'sage')],
|
||||
tagsApplied: true
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
const chip = container.querySelector('[data-chip-type="theme"]') as HTMLElement;
|
||||
expect(chip.style.backgroundColor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('omits color style for a tag with no color', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||||
tagsApplied: true
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
const chip = container.querySelector('[data-chip-type="theme"]') as HTMLElement;
|
||||
expect(chip.getAttribute('style')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
1
frontend/src/routes/search/chip-types.ts
Normal file
1
frontend/src/routes/search/chip-types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ChipType = 'sender' | 'directional' | 'date' | 'keyword' | 'theme';
|
||||
Reference in New Issue
Block a user