From fc557bd9ae33ed0312dd5f7c5694655caff19179 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 22:54:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(search):=20implement=20keyword=E2=86=92tag?= =?UTF-8?q?=20resolution=20in=20NlQueryParserService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keywords that substring-match the tag taxonomy become OR-union tag filters; non-matching keywords stay as FTS text. Resolved tags surface in the NlQueryInterpretation as TagHint objects with effective colours. The rawQuery fallback is now guarded by hadStructuredMatch to prevent double-apply when all keywords resolve. Co-Authored-By: Claude Sonnet 4.6 --- .../search/NlQueryParserService.java | 63 ++++++++++++++++--- .../search/NlQueryParserServiceTest.java | 37 +++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java index 4bf043e8..41d3dbd2 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java @@ -50,6 +50,11 @@ public class NlQueryParserService { List personNames = ext.personNames() != null ? ext.personNames() : List.of(); List keywords = ext.keywords() != null ? ext.keywords() : List.of(); + TagResolution tagResolution = resolveTags(keywords); + List resolvedTagHints = tagResolution.hints(); + List resolvedTagNames = tagResolution.tagNames(); + List remainingKeywords = tagResolution.remaining(); + NameResolution resolution = resolveNames(personNames); if (!resolution.ambiguous().isEmpty()) { @@ -64,31 +69,35 @@ public class NlQueryParserService { List noMatchFragments = resolution.noMatchFragments(); List 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, List.of(), ext.rawQuery(), false, 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, List.of(), ext.rawQuery(), keywordsApplied, false); + resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, resolvedTagHints, ext.rawQuery(), keywordsApplied, tagsApplied); return new NlSearchResponse(docs, interpretation); } @@ -128,14 +137,48 @@ public class NlQueryParserService { return new NameResolution(resolved, ambiguous, noMatchFragments, extraFragments); } + private TagResolution resolveTags(List keywords) { + LinkedHashSet seen = new LinkedHashSet<>(); + List remaining = new ArrayList<>(); + + for (String kw : keywords) { + if (kw == null || kw.length() < MIN_TAG_TERM) { + remaining.add(kw); + continue; + } + List 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 capped = seen.size() > MAX_RESOLVED_TAGS + ? new ArrayList<>(seen).subList(0, MAX_RESOLVED_TAGS) + : new ArrayList<>(seen); + + tagService.resolveEffectiveColors(capped); + + List hints = capped.stream() + .map(t -> new TagHint(t.getId(), t.getName(), t.getColor())) + .toList(); + List tagNames = capped.stream().map(Tag::getName).toList(); + + return new TagResolution(hints, tagNames, remaining); + } + private String buildText(List keywords, List noMatchFragments, - List extraFragments, String rawQuery) { + List extraFragments, String rawQuery, boolean hadStructuredMatch) { List 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; @@ -163,4 +206,10 @@ public class NlQueryParserService { List noMatchFragments, List extraFragments ) {} + + private record TagResolution( + List hints, + List tagNames, + List remaining + ) {} } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java index aa79b94a..29eaf0f9 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java @@ -441,4 +441,41 @@ class NlQueryParserServiceTest { assertThat(resp.interpretation().keywordsApplied()).isTrue(); } + + // --- 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 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(); + } }