feat(search): NL search — resolve tag names in query parsing #743
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of epic #735. Post-v1 refinement (v1 #738 folds all unresolved terms into
keywords; this issue resolves a subset of those terms against the tag taxonomy).Self-contained (backend tag resolution + frontend theme chips). Depends on #739 merged — the interpretation-chip components (
InterpretationChipRow.svelte) must exist before theme chips can be added.Goal
In v1, every term the LLM extracts as a
keyword("Hochzeit", "Krieg", "Kriegsende") is folded into PostgreSQL full-text search. In this iteration,NlQueryParserServiceadditionally resolves each extracted keyword against the existing tag taxonomy. Keywords that match one or more tags become tag filters (OR-union, with the existing hierarchy expansion); keywords that match nothing remain as FTS keywords — exactly as today. No LLM schema or prompt change is required.This makes a smart-search theme term behave the same as clicking a tag in the existing keyword search.
Design decisions (resolved)
keywordslist against tags inNlQueryParserService; matches → tag filters, misses → FTS keywords.RestClientOllamaClient(personNames,personRole,dateFrom,dateTo,keywords) and the prompt are untouched — lowest risk, no new model failure mode. Drops the stub'sthemeNamesfield.TagOperator.OR).ambiguousTagsconcept entirely (no picker, no suppressed results). Consistent with ADR-033 resolving tag names deterministically.personRole:"any"path resolves tags (does not skip).resolvedTagsis populated on this path for an honest interpretation payload;tagsApplied = falsekeeps the chips hidden. (Resolves OQ-4.)ILIKEqueries are sub-millisecond inside a multi-second LLM call — trivial. A truthful interpretation object is worth more than the micro-saving of skipping the lookup.TagHintcarries acolorfield; each chip renders (a) a tinted background from--c-tag-{color}and (b) a tinted left-border colour (border-left-color: var(--c-tag-{color})) as a chip-family visual marker — three redundant cues:Thema:prefix + tinted left border + tinted background. Both applied via inlinestyleconsistent withTagTreeNode.svelte/TagInput.svelte/themen/+page.svelte. Null-color fallback: omit thestyleattribute entirely; theThema:prefix is the sole differentiator. (Resolves OQ-5.)stylematches the established--c-tag-*pattern in the codebase.Corrections to the original stub (grounded in merged #738 code)
resolvedTags: List<Tag>→ ✅resolvedTags: List<TagHint>. The API never exposes JPA entities; persons surface as the lightweightPersonHint(id, displayName)record (search/PersonHint.java). Tags mirror this with a newTagHint(id, name, color).DocumentService.searchDocumentsconsumesSearchFilters.tags: List<String>(names), thenTagService.expandTagNamesToDescendantIdSetsexpands each name to its descendant-ID set. Passing names gives hierarchy expansion for free.ambiguousTags+ disambiguation flow → ✅ removed (see D2).Backend changes
1. New record —
search/TagHint.javaPlain record built from
Tagentities byNlQueryParserService(not a JPA projection — same pattern asPersonHint). Thecolorfield (D5) is the tag's effective colour: tag colours are stored only on root tags and inherited by children, so the parser must callTagService.resolveEffectiveColors(matched)on the matched entities before mapping toTagHint— otherwise child tags surface anullcolour.coloris not@Schema(requiredMode = REQUIRED)(it can legitimately be null).2. Extend
search/NlQueryInterpretation.javaAdd two fields (current fields:
resolvedPersons, ambiguousPersons, dateFrom, dateTo, keywords, rawQuery, keywordsApplied):Place
resolvedTagsafterkeywordsandtagsAppliedafterkeywordsApplied(keeps person/keyword/tag groupings adjacent).NlSearchResponseis unchanged.3.
TagService.findByNameContaining(String fragment): List<Tag>One-liner mirroring
PersonService.findByDisplayNameContaining. Delegates to the existingTagRepository.findByNameContainingIgnoreCase(fragment)— no new JPQL. Read method → no@Transactional(consistent withPersonServiceread-method style).4.
NlQueryParserService— keyword→tag resolutionCurrently (
search/NlQueryParserService.java~line 75-80) theSearchFiltersis built withtags = List.of(),tagQ = null,tagOperator = TagOperator.AND. New flow, inserted after keyword extraction and before thepersonRole:"any"branch (so bothkeywordsAppliedandtagsAppliedare known on every path). Resolve once, then branch — do not duplicate the resolution logic in both branches:remainingKeywordsonly (plus person no-match/extra fragments) — resolved terms are removed from the text predicate so they are not double-applied (tag filter AND text match would over-narrow).buildTextrawQueryfallback guard (correctness, not just coverage):buildText()currently falls back torawQuerywhen the joined parts are blank. If every keyword resolves to a tag and there are no person/no-match fragments, the FTS text would become blank →buildTextreturnsrawQuery, re-introducing the resolved terms as text AND applying them as tags — the exact double-apply this issue forbids. The resolved-term removal must produce an empty/null FTS text in this case, not arawQueryfallback (e.g. only fall back torawQuerywhen nothing was resolved — no tags AND no persons — or thread aboolean hadStructuredMatchintobuildText). Pin this with an explicit test (see Testing).anypath): buildSearchFilterswithtags = <resolved tag names>,tagOperator = TagOperator.OR,tagQ = null. SettagsApplied = true. Person (sender/receiver) and date predicates remain AND-composed with the tag predicate; OR applies only within the tag set. (Confirmed:DocumentSpecifications.hasTagPartialhas its own null guard and fires only whentagQis non-blank; thetagslist filter is a separate specification that fires independently. No guard inDocumentServicesuppresses it whentagQ = null.)personRole:"any"single-person path (searchDocumentsByPersonId) supports neither keywords nor tags → settagsApplied = false(mirrors the existingkeywordsApplied = false). Per D4,resolvedTagsis still populated for transparency; the frontend hides the chips whentagsApplied == false. (This path already early-returns before theSearchFiltersbuild site ~line 63 — confirmed.)MIN_TAG_TERM(3, accepted — resolves OQ-1) andMAX_RESOLVED_TAGS(10, mirrors personMAX_CANDIDATES = 10— resolves OQ-2), declared as named constants inNlQueryParserServicenext toMAX_CANDIDATES. Both have value10but model different concepts (person resolution candidate cap vs. tag resolution cap) — keep them as separate named constants; they may diverge independently. When matches exceed the cap, take the first N andlog.debug("Keyword matched {} tags; capping at {}", count, MAX_RESOLVED_TAGS)— count only, never the raw keyword at info/warn (parameterized SLF4J; LLM-derived input echoes private names lifted from the query; defends against log injection + PII leak per ADR-028).Security / defense-in-depth
Keyword fragments are already length-capped to 100 chars (
MAX_KEYWORD_LENGTH) byRestClientOllamaClient.toExtraction()before reaching the parser; no new validation needed before theTagRepositorycall. Queries are parameterized (LIKE LOWER(CONCAT('%', :q, '%'))).MIN_TAG_TERM >= 3also blunts trivial 1–2-char enumeration of the tag taxonomy.TagHint.coloris a closed enum value fromTagService.ALLOWED_TAG_COLORS— use the established inlinestyleattribute pattern from existing tag-color components (TagTreeNode.svelte,TagInput.svelte,themen/+page.svelte):style="background-color: var(--c-tag-{color}); border-left-color: var(--c-tag-{color})".TagHint.coloris a closed DB-origin set and Svelte auto-escapes attribute values; no{@html}needed.TagHint.nameoriginates from the database (Tag entity name resolved from the taxonomy), not from LLM output directly — a tighter XSS surface than keyword chips. The endpoint authz (@RequirePermission(Permission.READ_ALL)onPOST /api/search/nl) is unchanged — no new endpoint, no new permission decision.Documentation
Amend
docs/adr/028-nl-search-ollama.mdin this PR:isHealthy()wording — recommended resolution: document over wire. ADR-028 statesisHealthy()is "called inline before each inference request." It is not —search()callsollamaClient.parse()directly;isHealthy()has zero callers insearch/.parse()already throws on connection failure (caught asSMART_SEARCH_UNAVAILABLE); a pre-callisHealthy()probe would add ~50–150 ms latency for a failure mode already covered. Update ADR-028 to state:isHealthy()is reserved for ops/health endpoint polling; the in-path degradation mechanism isparse()'s timeout/IOException handling. No code change — one ADR paragraph. (Pre-existing; flagged in review.)Add the new term "keyword→tag resolution" / "theme chip" to
docs/GLOSSARY.md.C4 diagram check: the
search/package gains a new cross-domain dependency ontag/(TagService). Checkdocs/architecture/c4/l3-backend-*.puml— ifNlQueryParserServiceis diagrammed, add a dependency arrow toTagService.Frontend changes (depends on #739)
Pre-implementation step:
ChipTypeis currently defined in two files —InterpretationChipRow.svelte:7and+page.svelte:272(both'sender' | 'directional' | 'date' | 'keyword'). Extract tofrontend/src/routes/search/chip-types.tsand import from both files before the PR. When'theme'is added, it becomes the single source of truth.Extend
InterpretationChipRow.svelte(introduced by #739) with theme chips, and extend the chip-removal machinery in the parent page:interpretation.resolvedTagsentry, only wheninterpretation.tagsApplied === true(same rule askeywordsAppliedfor keyword chips — do not show greyed/disabled chips).style): each theme chip renders (a) a tinted background and (b) a tinted left-border colour usingstyle="background-color: var(--c-tag-{tag.color}); border-left-color: var(--c-tag-{tag.color})"— consistent withTagTreeNode.svelte,TagInput.svelte, andthemen/+page.svelte. Three redundant cues:Thema:prefix + tinted left border + tinted background. Whentag.coloris null, omit thestyleattribute entirely — the chip renders in the standard neutral appearance; theThema:prefix is the sole differentiator in this case.{:else if chip.type === 'theme'}branch — do not reuse the generic{:else}branch. The generic branch placeschip.labelinside thenameSpantruncated element; for theme chips, theThema:prefix and the×button must both remain outside the truncated span (only the tag name itself truncates). Mirror the structure of thedirectionalbranch. Adddata-chip-type="theme"to the chip wrapper<span>(consistent withdata-chip-type={chip.type}on other branches — required for test assertions).[Thema: Weltkrieg ×](new i18n keysearch_chip_theme_prefix).GET /api/documents/searchparam names (corrected; resolves OQ-3): clicking × drops that tag and re-runs viaGET /api/documents/search. Theme chips passvalue = tag.nametoonRemoveChip('theme', value). Extend theonRemoveChipcallback union with'theme'(one callback, switch on type — do not add a parallel callback). Route the re-run through the samewithCsrf()wrapper as the other chips for consistency.paramsFromInterpretation()extension andremoveChipswitch (parent page —+page.svelte):paramsFromInterpretation()at line 261 currently returns{senderId, receiverId, from, to, q}. The multi-valuetag[]params cannot be expressed in this object shape. Choose one approach before implementing: (a) addtags: string[]andtagOp: stringto the return type and extendapplyResolvedAndSearchto accept them; or (b) buildURLSearchParamsdirectly in the'theme'case ofremoveChipwithout changing the return type. Be consistent acrossremoveChipandapplyResolvedAndSearch.removeChipif/elseswitch at line 274 also needs a'theme'case — this is a separate change fromparamsFromInterpretation(). Without it, clicking a theme chip's × callsapplyResolvedAndSearchwith the full interpretation params intact (no tag removed). The theme branch: drop the selected tag fromresolvedTags, reconstruct remaining tag names, emit astagparams +tagOp=OR.resolvedTagsis non-empty after removing the tapped tag:params.append('tag', name)for each remaining name +params.set('tagOp', 'OR'); when the last theme chip is removed, omit bothtagandtagOp. Any still-active person/date filters from the interpretation are preserved in the params as before. No re-parse via the NL endpoint — existing chip removal already clears NL state (smartMode=false,nlInterpretation=null) and switches to regular search; theme chip removal follows the same pattern.{#each}key:"theme:" + tag.id. Use$derivedfor the chip list and thetagsAppliedgate (not$effect).focus-visible:ring-2on the wrapper (not just the ×);truncateon the label span atmax-w-[8rem](mobile) with theThema:prefix and the × button both outside the truncated span — the prefix is the sole non-colour differentiator for colour-blind users and must never be clipped at any viewport width;flex flex-wrap gap-2container must absorb up toMAX_RESOLVED_TAGS=10 theme chips + person/date chips without pushing the result list off the first screen.aria-labelviasearch_filter_remove_label(Filter entfernen: {label}), where{label}includes the "Thema" prefix ("Filter entfernen: Thema Weltkrieg"). No{@html}— tag names are LLM-influenced; rely on Svelte text-position auto-escaping.New i18n keys (
messages/{de,en,es}.json)search_chip_theme_prefixOpenAPI / types
Run
npm run generate:apiin the same PR —TagHint(withid,name,color) and the two newNlQueryInterpretationfields must appear insrc/lib/generated/.Testing
TypeScript fixture audit (pre-implementation): before running
npm run generate:api, rungrep -rn 'NlQueryInterpretation' frontend/to find all existing mock objects. The type regen adds two required fields (resolvedTags: TagHint[],tagsApplied: boolean) — every mock object missing them causesnpm run checkfailures across spec files. AddresolvedTags: []andtagsApplied: falseas neutral defaults to each found site. Confirmed primary site:makeInterpretation()factory atInterpretationChipRow.svelte.spec.ts:14.Backend —
NlQueryParserServiceTestconstructor sync (pre-implementation): The test at line 43 manually constructs the service:new NlQueryParserService(ollamaClient, personService, documentService). AddingTagServiceas the 4th constructor arg breaks all existing tests at compile time. Add@Mock TagService tagServiceand a default stubwhen(tagService.findByNameContaining(anyString())).thenReturn(List.of())insetUp()alongside the existing service stubs.Backend —
NlQueryParserServiceTest(Mockito), additions (assertfilters.tags()andfilters.tagOperator()with exact matchers, neverany()— a vacuousany()lets an accidentalANDdefault pass; writetag(name)/tagHint(id,name,color)factory helpers first, following the existing lowercase private-method convention):resolvedTagscontains it,tagsApplied == true, removed from FTS text; assertsearchDocumentscalled withfilters.tags() == [name]andfilters.tagOperator() == TagOperator.ORresolvedTags; assert OR-union filterresolvedTagsemptypersonRole:"any"+ a resolvable keyword →searchDocumentsByPersonIdpath taken,searchDocumentsnever called (verify(..., never())),tagsApplied == false,resolvedTagsstill populated (D4)MAX_RESOLVED_TAGStags → capped, debug-logged (count only)MIN_TAG_TERM) → skipped from tag resolution, stays as keywordresolvedTags(LinkedHashSet)rawQueryguard (latent bug): every keyword resolves and there are no person/no-match fragments → assertfilters.text() == nullandfilters.tags()populated (no double-apply)keywordsApplied == falseandtagsApplied == truein the same interpretation (frontend contract: keyword chips hidden, theme chips shown)TagHint.colorequals the parent's (exercisesresolveEffectiveColors); build the mockTagentity fresh per test (not a shared field) —resolveEffectiveColorscallstag.setColor(...)in-place and bleeds into other tests that share the same entity instanceTagHint.color == null. This pins the known one-level depth constraint ofresolveEffectiveColorsas a documented limitation rather than a silent production bug. If the archive's taxonomy grows deeper, fix the method to recurse and update this test.Backend —
TagServiceTest:findByNameContainingdelegates tofindByNameContainingIgnoreCase(red → green). Do not test the trivialTagHintrecord accessors.Backend — descendant-expansion integration test (
@DataJpaTest,postgres:16-alpine): create a parent tag + a child tag; a document tagged only on the child; assert an NL search with a keyword substring-matching the parent name returns that document. VerifiesexpandTagNamesToDescendantIdSetsfires end-to-end — the acceptance criterion "filtered by the OR-union of every tag … and their descendants" is not provable via Mockito alone. Usepostgres:16-alpine(project convention — never H2). Check for an existing Testcontainers base class (NlSearchControllerTest.java— inspect its imports) before declaring a new container.Coverage gate: target is 88% branch coverage (
backend/CLAUDE.md). The new branches — short-term skip, cap, dedup, all-resolve guard, flag independence — are the ones easiest to miss; verify each is hit.Frontend —
InterpretationChipRow.svelte.spec.ts(vitest-browser-svelte):resolvedTagsnon-empty +tagsApplied: true→ theme chips render withThema:prefixtagsApplied: false→ assert DOM absence viaquerySelectorAll('[data-chip-type="theme"]')— not[data-chip-type]which would also catch other chip typescolor: "sage"renders with inlinestylecontainingbackground-color: var(--c-tag-sage)andborder-left-color: var(--c-tag-sage); a chip withcolor: nullrenders with no tint style (standard neutral chip appearance)onRemoveChip('theme', tag.name)called with the tag's name asvalue(assert callback receives the name string — the HTTP call is the parent component's responsibility, not the chip row's)Frontend —
paramsFromInterpretation()unit tests (vitest): the parent page's param-reconstruction function must be tested for the theme-chip branch covering: (a) N remaining tags → exactly Ntagparams +tagOp=OR; (b) last tag removed → notag/tagOpin params; (c) last tag removed with a resolved person present → sender param intact, notag/tagOp; (d) null-color tag → tag name is emitted correctly (color does not affect param construction).No new Playwright test required — extend the existing #739 happy-path E2E fixture to include
resolvedTags+tagsApplied: true. Locate the fixture file first: runfind frontend/e2e -name "*.spec.ts" | xargs grep -l "smartMode". Also add a "last theme chip removed, person filter survives" scenario: NL result with one resolved person + one theme tag; remove the theme chip; assert notag/tagOpin the resulting URL but the sender UUID param is present.Acceptance criteria
POST /api/search/nlwith{"query": "Briefe über den Krieg"}returns results filtered by the OR-union of every tag whose name substring-matches "Krieg" (and their descendants), with those tags ininterpretation.resolvedTagsandtagsApplied == trueresolvedTags(v1 behaviour preserved)resolvedTags(dedup)rawQueryresolvedTagsentry carries its effectivecolor(own colour, or the inherited parent colour for a direct child tag with no own colour; null for grandchild+ tags or when neither the tag nor its parent has a colour)personRole:"any"plus a theme term runs the sender-OR-receiver search;tagsApplied == false;resolvedTagsis still populated; the frontend shows no theme chips[Thema: … ×]with the tag's own--c-tag-{color}applied via inlinestyle(background-color+border-left-color); null-color falls back to standard neutral chip appearance (no style attribute); whentagsApplied == true; removing one re-runsGET /api/documents/searchwithtagOp=ORand the remainingtagvalues; removing the last theme chip omits bothtagandtagOpwhile any still-active sender/receiver/date filters remain intactGET /api/documents/searchbehaviour is otherwise unchangedRestClientOllamaClientare unchanged (nothemeNamesfield)npm run generate:apihas been run;TagHint(id,name,color),resolvedTags,tagsAppliedexist in generated typesnpm run checkpasses with zero new errors after addingresolvedTags: []andtagsApplied: falseto all existingNlQueryInterpretationmock objectsNon-functional checklist
ILIKEper extracted keyword (typically 1–3), bounded byMAX_RESOLVED_TAGS;resolveEffectiveColorsadds one batched parent-colour lookup (confirmed:tagRepository.findAllById(parentIdsNeeded)— single IN query). Adds < ~10ms to a 2–15s LLM call — negligible. The existing/api/search/nlPrometheus latency exclusion (#738) already covers it. Pre-merge check: verify #738's Prometheus exclusion/histogram actually landed inmain—curl -s http://localhost:8080/actuator/prometheus | grep 'http_server_requests.*search/nl'should return a series; if absent, fix before merging. Document this check in the PR description as a merge checklist item. Known future lever (not this PR):LIKE LOWER(CONCAT('%', :q, '%'))is a leading-wildcard match → no B-tree index; trivial seq-scan on the smalltagtable today. Iftagever grows past a few thousand rows, add apg_trgmGIN index onlower(name).MIN_TAG_TERM >= 3blunts taxonomy enumeration;TagHint.coloris a closed DB-origin palette token applied via inlinestyle(Svelte auto-escapes attribute values);TagHint.nameis DB-origin (not LLM text); LLM-derived tag names never reachinnerHTML(no{@html}) and are logged only as counts.log.debug) — never the raw query or tag names beyond debug level (PII policy from ADR-028). No new metric or dashboard panel (sub-ms inside a multi-second request).aria-label(with "Thema" prefix), and focus-ring patterns. Run axe in both light and dark mode on the chip row —--c-tag-*tokens are remapped in dark mode and a token passing 4.5:1 in light can fail in dark. Perform a visual wrap stress test at 320px with 10 stub theme chips to confirm theflex flex-wrap gap-2container absorbs them without pushing results off screen.Out of scope
themeNamesfield / system-prompt change (explicitly rejected — see D1)ambiguousTags(rejected — see D2)personRole:"any"search path (returnstagsApplied = false; chips hidden — see D4)tagsApplied: falsehint shown on thepersonRole:"any"path (by design — D4 transparency is API-level, not UI-level; no analogue to the keywordshowKeywordsNotAppliedhint)nl-search-spec.htmltheme-chip mockup (nice-to-have follow-up; design review does not gate this PR — see D5)pg_trgmindex ontag.name(future lever, only if the table grows)resolveEffectiveColorsfor grandchild+ color inheritance (future lever if taxonomy grows deeper than 2 levels)Implementation complete ✅
All 12 tasks implemented via Red→Green→Refactor TDD. Branch:
feat/issue-743-nl-search-tag-resolutionCommits (12)
8bd83908feat(search): add TagService.findByNameContaining for NL tag resolution89051350feat(search): add TagHint record for NL tag resolution API surface7eee688cfeat(search): extend NlQueryInterpretation with resolvedTags + tagsAppliede94414b8refactor(search): extract ChipType to chip-types.ts; audit NL fixturesfc557bd9feat(search): implement keyword→tag resolution in NlQueryParserService6cb10258test(search): add 11 tag-resolution test cases to NlQueryParserServiceTest86690fdbtest(search): DataJpaTest for descendant-expansion via TagRepository573bca49feat(i18n): add search_chip_theme_prefix to de/en/es message bundles847874abfeat(api): add TagHint schema and extend NlQueryInterpretation with resolvedTags/tagsApplied5387bc92feat(search): render removable theme chips in InterpretationChipRow2a7e1337feat(search): wire theme chip removal to URL navigation in +page.svelte64b7b231docs(search): ADR-028 fix + glossary + C4 diagram for tag resolution (#743)What was built
Backend:
TagService.findByNameContaining(fragment)— case-insensitive substring match via existingfindByNameContainingIgnoreCaseTagHintrecord (id,name,color?) — lightweight representation for the API surfaceNlQueryInterpretationextended withresolvedTags: List<TagHint>andtagsApplied: booleanNlQueryParserService.resolveTags()— iterates LLM-extracted keywords; each keyword withlength ≥ 3is substring-matched against tags; matches go toresolvedTags(OR-union filter, deduplicated viaLinkedHashSet, capped at 10); non-matching keywords stay as FTS textbuildTextrawQuery fallback guarded byhadStructuredMatch— prevents raw query double-apply when all keywords resolve to tagstagOperator = ORwhen resolved tags are non-empty;tagsApplied = falseon thepersonRole:anysingle-person path (no tag filter slot there, butresolvedTagsstill populated for UI transparency)TagService.resolveEffectiveColors()called on the resolved tag list (one-level color inheritance)NlQueryParserServiceTestcases covering all resolution scenariosNlSearchTagResolutionIntegrationTest(@DataJpaTest+ Testcontainers) verifying the recursive CTEfindDescendantIdsByNameexpands parent → childFrontend:
messages/{de,en,es}.json—search_chip_theme_prefix: "Thema" / "Topic" / "Tema"api.ts(manually updated,generate:apirun) —TagHintschema +resolvedTags/tagsAppliedinNlQueryInterpretationInterpretationChipRow.svelte—Chipunion extended withthemevariant;resolvedTagsloop whentagsApplied; inlinestylefromtagColorStyle()for coloured chips;remove()passeschip.tag.nameas value; 6 new browser component teststheme-chip-removal.ts— pure functionbuildThemeRemovalUrl(interp, removedTagName): stringbuilding the correct/documents?tag=…&tagOp=ORURL with person/date params preserved; 5 pure-function server tests+page.svelte—removeChip'theme' case callsbuildThemeRemovalUrl, thenresetNlState() + goto()e2e/nl-search.spec.ts—Thema: Weltkriegchip assertion in existing test; new "last theme chip removed keeps person params" scenarioDocs:
isHealthy()paragraph (it has zero callers insearch/, degradation is viaparse()timeout); added keyword→tag resolution paragraph with reference to ADR-033GLOSSARY.md: addedkeyword→tag resolution,TagHint,theme chipentries; updatedNlQueryInterpretationentrydocs/architecture/c4/l3-backend-3h-search.puml: addedTagServicecomponent +findByNameContaining/resolveEffectiveColorsrelation arrowsNext step
Open PR from
feat/issue-743-nl-search-tag-resolution→main.