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:
@@ -50,6 +50,11 @@ public class NlQueryParserService {
|
|||||||
List<String> personNames = ext.personNames() != null ? ext.personNames() : List.of();
|
List<String> personNames = ext.personNames() != null ? ext.personNames() : List.of();
|
||||||
List<String> keywords = ext.keywords() != null ? ext.keywords() : 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);
|
NameResolution resolution = resolveNames(personNames);
|
||||||
|
|
||||||
if (!resolution.ambiguous().isEmpty()) {
|
if (!resolution.ambiguous().isEmpty()) {
|
||||||
@@ -64,31 +69,35 @@ public class NlQueryParserService {
|
|||||||
List<String> noMatchFragments = resolution.noMatchFragments();
|
List<String> noMatchFragments = resolution.noMatchFragments();
|
||||||
List<String> extraFragments = resolution.extraFragments();
|
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())) {
|
if (resolved.size() == 1 && isAnyRole(ext.personRole())) {
|
||||||
UUID personId = resolved.get(0).id();
|
UUID personId = resolved.get(0).id();
|
||||||
DocumentSearchResult docs = documentService.searchDocumentsByPersonId(
|
DocumentSearchResult docs = documentService.searchDocumentsByPersonId(
|
||||||
personId, ext.dateFrom(), ext.dateTo(), pageable);
|
personId, ext.dateFrom(), ext.dateTo(), pageable);
|
||||||
NlQueryInterpretation interpretation = new NlQueryInterpretation(
|
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);
|
return new NlSearchResponse(docs, interpretation);
|
||||||
}
|
}
|
||||||
|
|
||||||
UUID sender = buildSender(resolved, ext.personRole());
|
UUID sender = buildSender(resolved, ext.personRole());
|
||||||
UUID receiver = buildReceiver(resolved, ext.personRole());
|
UUID receiver = buildReceiver(resolved, ext.personRole());
|
||||||
|
|
||||||
|
boolean tagsApplied = !resolvedTagHints.isEmpty();
|
||||||
|
TagOperator tagOperator = tagsApplied ? TagOperator.OR : TagOperator.AND;
|
||||||
|
|
||||||
SearchFilters filters = new SearchFilters(
|
SearchFilters filters = new SearchFilters(
|
||||||
text.isBlank() ? null : text,
|
text.isBlank() ? null : text,
|
||||||
ext.dateFrom(), ext.dateTo(),
|
ext.dateFrom(), ext.dateTo(),
|
||||||
sender, receiver,
|
sender, receiver,
|
||||||
List.of(), null,
|
resolvedTagNames, null,
|
||||||
null, TagOperator.AND, false);
|
null, tagOperator, false);
|
||||||
|
|
||||||
DocumentSearchResult docs = documentService.searchDocuments(filters, DocumentSort.DATE, "desc", pageable);
|
DocumentSearchResult docs = documentService.searchDocuments(filters, DocumentSort.DATE, "desc", pageable);
|
||||||
boolean keywordsApplied = !text.isBlank();
|
boolean keywordsApplied = !text.isBlank();
|
||||||
NlQueryInterpretation interpretation = new NlQueryInterpretation(
|
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);
|
return new NlSearchResponse(docs, interpretation);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,14 +137,48 @@ public class NlQueryParserService {
|
|||||||
return new NameResolution(resolved, ambiguous, noMatchFragments, extraFragments);
|
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,
|
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<>();
|
List<String> parts = new ArrayList<>();
|
||||||
parts.addAll(keywords);
|
parts.addAll(keywords);
|
||||||
parts.addAll(noMatchFragments);
|
parts.addAll(noMatchFragments);
|
||||||
parts.addAll(extraFragments);
|
parts.addAll(extraFragments);
|
||||||
String text = String.join(" ", parts).strip();
|
String text = String.join(" ", parts).strip();
|
||||||
if (text.isBlank() && rawQuery != null && !rawQuery.isBlank()) {
|
if (text.isBlank() && !hadStructuredMatch && rawQuery != null && !rawQuery.isBlank()) {
|
||||||
return rawQuery;
|
return rawQuery;
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
@@ -163,4 +206,10 @@ public class NlQueryParserService {
|
|||||||
List<String> noMatchFragments,
|
List<String> noMatchFragments,
|
||||||
List<String> extraFragments
|
List<String> extraFragments
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private record TagResolution(
|
||||||
|
List<TagHint> hints,
|
||||||
|
List<String> tagNames,
|
||||||
|
List<String> remaining
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,4 +441,41 @@ class NlQueryParserServiceTest {
|
|||||||
|
|
||||||
assertThat(resp.interpretation().keywordsApplied()).isTrue();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user