feat(search): implement keyword→tag resolution in NlQueryParserService

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-06 22:54:33 +02:00
committed by marcel
parent 39ff63921d
commit dcd0e725a7
2 changed files with 93 additions and 7 deletions

View File

@@ -50,6 +50,11 @@ 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()) {
@@ -64,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, 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<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;
@@ -163,4 +206,10 @@ public class NlQueryParserService {
List<String> noMatchFragments,
List<String> extraFragments
) {}
private record TagResolution(
List<TagHint> hints,
List<String> tagNames,
List<String> remaining
) {}
}

View File

@@ -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<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();
}
}