feat(search): NL search backend — query parser, controller, rate limiting #738
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. Depends on infra issue (Ollama service) — add Gitea issue number when created.
Goal
Implement the Spring Boot backend for natural language search: a new
search/domain package that parses a German natural-language query via Ollama (Qwen 2.5 7B), resolves names to person UUIDs, and delegates to the existingDocumentService.searchDocuments(). The existing search endpoint is not modified.Architecture
ADR
Write
docs/adr/028-nl-search-ollama.mdbefore writing any code. Cover:maxLengthconstraintsapp.ollama.timeout-seconds=30default and justificationollama pull qwen2.5:7b-instruct-q4_K_Mmust run before first inferencepersonRoleapplies to single-name queries only — chosen over per-name roles because the most natural German phrasing ("Was hat Walter an Emma geschrieben?") strongly implies sender→receiver order, and per-name roles would require a combinatorially complex schemapersonRole: "any"keyword limitation: keyword filtering is not supported for OR-semantics person queries — only person identity and date range are applied;keywordsApplied = falseis returned in the response (KISS over completeness; disambiguation and date filters already narrow results significantly)search/→person/+document/dependency direction is intentional:NlQueryParserServicecallsPersonServiceandDocumentService— cross-domain service calls, not repository leaks.New package:
org.raddatz.familienarchiv.search/OllamaClientNlQueryInterpretation parse(String query)OllamaHealthClientboolean isHealthy()— called inline byNlQueryParserServicebefore each inference call. No ActuatorHealthIndicatorbean needed — consistent withOcrHealthClientpatternRestClientOllamaClient@ServiceimplementsOllamaClient,OllamaHealthClientisHealthy()callsGET /api/tags(Ollama has no/healthendpoint); degrades gracefully when Ollama is absent. Uses two separateRestClientinstances: inference client (30 s timeout) and health-check client (2 s connect timeout) — seeOllamaProperties.healthCheckTimeoutSeconds.NlQueryParserService@ServiceOllamaClient,PersonService,DocumentServiceOllamaProperties@Component @ConfigurationProperties("app.ollama") @DatabaseUrl,model,timeoutSeconds(inference, default: 30),healthCheckTimeoutSeconds(health-check RestClient only, default: 2). String fields (baseUrl,model) are null if absent from yaml — explicit yaml entries are required.NlSearchRateLimiter@ServiceuserKey(email string fromprincipal.getUsername()), 5 req/min. Node-local — in multi-replica deployments the effective limit multiplies by replica count (same caveat asLoginRateLimiter). Has package-privateresetForTest()— callscache.invalidateAll()(not key-based invalidation, which would couple the method to the@WithMockUserusername value).NlSearchRateLimitProperties@Component @ConfigurationProperties("app.nl-search.rate-limit") @DatamaxRequestsPerMinute(default: 5)NlSearchController@RestControllerPOST /api/search/nlNlSearchRequest@NotBlank @Size(min=3, max=500) String queryPersonHintUUID id, String displayName— lightweight person reference for search results; not a JPA projectionNlQueryInterpretationList<PersonHint> resolvedPersons,List<PersonHint> ambiguousPersons,LocalDate dateFrom,LocalDate dateTo,List<String> keywords,String rawQuery,boolean keywordsAppliedNlSearchResponseDocumentSearchResult result,NlQueryInterpretation interpretationModel
OllamaClient/OllamaHealthClient/RestClientOllamaClienton the existingOcrClient/OcrHealthClient/RestClientOcrClientsplit.Note on
PersonHint:PersonSummaryDTOis a JPA interface projection — it cannot be constructed manually.PersonHintis a plain record built fromPersonentities byNlQueryParserService.Config properties pattern:
OllamaPropertiesandNlSearchRateLimitPropertiesfollow the same pattern asRateLimitProperties(auth/RateLimitProperties.java):@Component,@ConfigurationProperties(...),@Data. All three annotations are required — without@Componentthe bean doesn't auto-register; without@Datathe binder cannot set fields.Ollama JSON schema (grammar-constrained)
Defense-in-depth:
NlQueryParserServicemust also enforce these length limits in code before passing any LLM-extracted fragment toPersonRepository.searchByName().Null-coalescing: Treat every field as nullable —
personNamesandkeywordsabsent from the Ollama response must be coalesced toList.of()before processing. A fully absent response falls back torawQueryas a keyword.Defensive
personRoleparsing: If Ollama returns a value not in["sender", "receiver", "any"](e.g. due to model drift), log a warning and default to"any"rather than propagating aJsonMappingException. Use parameterized SLF4J:log.warn("Unexpected personRole from Ollama: {}", value)— never string concatenation (defends against log injection from LLM output).Name resolution
PersonService.findByDisplayNameContaining(String fragment): List<Person>delegates to the existingPersonRepository.searchByName(fragment)— no new JPQL needed; keep as a one-liner. The existing query already covers first name + last name (both orderings), alias, and name aliases (maiden names) viaLEFT JOIN p.nameAliases. This is a read method — no@Transactionalannotation (consistent withPersonServicecode style for read methods).Max candidates cap:
NlQueryParserServicepasses at most 10 persons toambiguousPersons. IfsearchByName()returns more than 10 results, take the first 10 and loglog.debug("Name '{}' matched {} persons; capping disambiguation at 10", name, results.size()). No new response fields — the frontend shows up to 10 disambiguation candidates. This prevents unusable disambiguation UIs for common surnames.PersonHint→senderIdorreceiverIdbased onpersonRoleDocumentSearchResultreturned with matched persons (up to 10) asList<PersonHint>inambiguousPersons— frontend shows disambiguation UI. Disambiguation takes priority regardless ofpersonRole— this applies even whenpersonRoleis"any".keywordspersonRole: "any"(single match) → callDocumentService.searchDocumentsByPersonId(UUID personId, LocalDate from, LocalDate to, Pageable pageable)— queries documents where that person is sender OR receiver. Keywords from the NL interpretation are not applied for this path — only person identity and date range filter results. SetkeywordsApplied = falsein the returnedNlQueryInterpretation. The frontend must not show keyword chips whenkeywordsApplied == false.Date strings from Ollama (
"1914","1914-01-01") → parse toLocalDate:from = 1914-01-01,to = 1914-12-31(or range if specified). Store asLocalDateinNlQueryInterpretation, serialized to ISO-8601 in the response. Malformed date strings that cannot be parsed to a year or ISO date are treated asnull(not folded into keywords).Multi-name query resolution
When
personNamescontains exactly 2 entries (e.g.["Walter", "Emma"]from "Was hat Walter an Emma geschrieben?"):DocumentService.searchDocuments(senderId=person1, receiverId=person2, ...). Both appear inresolvedPersons.resolvedPersons; the ambiguous name's candidates appear inambiguousPersons. Frontend shows disambiguation UI for the ambiguous name. The user must resolve the ambiguity before the search proceeds — the resolved name does not trigger a partial search.keywords; the resolved name is used as a single-name search withpersonRole.keywords.personRolefrom the Ollama schema applies only to single-name queries. For 2-name queries, roles are implicit: first=sender, second=receiver.Consistency rule:
ambiguousPersonsnon-empty always means the search result is empty and disambiguation UI is shown — regardless of whether any other names in the query resolved cleanly.Response list order semantics: in
NlQueryInterpretation.resolvedPersons, index 0 is the sender candidate and index 1 is the receiver candidate for 2-name queries. The frontend interpretation chip must render this directionally (e.g. "Walter → Emma"), not as an unordered list.DocumentServicechangesAdd a new overload using JPQL (not native SQL):
Use JPQL
MEMBER OF— do NOT use native SQLANY()which is PostgreSQL-specific. Date predicates must be in the JPQL query itself — post-query filtering breaks pagination correctness.Call-site defaults for
NlQueryParserServiceKeywords → text join:
String.join(" ", interpretation.keywords())maps towebsearch_to_tsqueryAND semantics in PostgreSQL —"Krieg Walter"finds documents mentioning both. An empty list produces an empty string, which has no effect on the FTS predicate.Keyword-only path (no resolved persons):
2-name resolved path (both names resolve, sender + receiver):
The Mockito
verify()calls inNlQueryParserServiceTestmust assert the exact values ofsort,dir,tagOperator,status, andundated— notany(). Without explicit matchers the test passes vacuously for wrong defaults.Error codes
SMART_SEARCH_UNAVAILABLESMART_SEARCH_RATE_LIMITEDVALIDATION_ERRORAdd each to:
ErrorCode.java→errors.ts→getErrorMessage()→messages/{de,en,es}.json.i18n guidance:
SMART_SEARCH_UNAVAILABLE: de: "Die intelligente Suche ist momentan nicht verfügbar. Bitte nutze die normale Suche."; en: "The smart search is currently unavailable. Please use the regular search."; es: "La búsqueda inteligente no está disponible en este momento. Por favor, usa la búsqueda normal."SMART_SEARCH_RATE_LIMITED: de: "Du hast die Suchfunktion zu häufig genutzt. Bitte warte eine Minute."; en: "You have used the search function too frequently. Please wait a minute."; es: "Has utilizado la función de búsqueda demasiadas veces. Por favor, espera un minuto."smart_search_keywords_not_applied(forkeywordsApplied == falsefrontend display): de: "Schlüsselwörter konnten bei dieser Suche nicht berücksichtigt werden."; en: "Keywords could not be applied to this search."; es: "Las palabras clave no pudieron aplicarse a esta búsqueda."Rate limiting
New
NlSearchRateLimiterbean — do not reuseLoginRateLimiter(keyed onip + ":" + email). Use the same Bucket4j + Caffeine pattern, keyed onprincipal.getUsername()(email string — stable, injection-proof, already guaranteed by the session). Config viaNlSearchRateLimitProperties(@ConfigurationProperties("app.nl-search.rate-limit")), defaultmaxRequestsPerMinute = 5. Return HTTP 429 withSMART_SEARCH_RATE_LIMITEDwhen exceeded.Add a package-private
resetForTest()method that callscache.invalidateAll()— not key-based invalidation (which would couple the method to the@WithMockUser(username = "testuser")value).invalidateAll()is simpler and avoids this coupling.Security
@RequirePermission(Permission.READ_ALL)onPOST /api/search/nl— this is a read operation, notWRITE_ALL.log.debug("NL search: queryLength={}, personNamesCount={}, latencyMs={}", ...)— never log the raw query (PII).expose:notports:. Hard requirement: the Ollama inference API has no authentication by default;ports:would make it reachable from the host network, allowing arbitrary model inference.Controller —
@AuthenticationPrincipalwiringDo NOT use
@AuthenticationPrincipal AppUser.CustomUserDetailsService.loadUserByUsername()returnsnew User(email, password, authorities)— a SpringUser, notAppUser. At runtime,@AuthenticationPrincipal AppUseralways resolves tonull; the rate limiter call would NPE on every request, causing rate limiting to fail open.Correct approach: use
@AuthenticationPrincipal UserDetails principaland passprincipal.getUsername()(email string) toNlSearchRateLimiter.checkAndConsume(String userKey). This matches theInviteControllerpattern. In@WebMvcTest,@WithMockUser(username = "testuser", authorities = {"READ_ALL"})works directly — no@WithUserDetailsorUserDetailsServicemocking needed.WireMock dependency
Add
org.wiremock:wiremockversion 3.9.x (notwiremock-standalone) astestscope inpom.xmlas the first commit on the implementation branch. Verify the coordinate before committing: runmvn dependency:get -Dartifact=org.wiremock:wiremock:3.9.2:jarto confirm it resolves from Maven Central — the oldcom.github.tomakehurstgroupId was retired in the 3.x repackage. The standalone artifact bundles its own Jackson and conflicts with Spring Boot's Jackson on the classpath. WireMock 3.9.x is compatible with Java 21 and Spring Boot 4 — verify the latest stable 3.9.x patch on Maven Central before pinning.Configuration —
application.yaml@ConfigurationProperties("app.ollama")requires explicit yaml entries (unlike@Valuewhich supports annotation-level defaults). String fields are null if the key is absent. Add toapplication.yaml:application-dev.yamlalready exists atbackend/src/main/resources/application-dev.yaml— add to it, do not create a new file. Add the dev override key for local development (where Ollama runs on the host, not inside Docker):(
health-check-timeout-secondsdoes not need a dev override — 2 seconds is appropriate in all environments.)Test architecture
Commit order:
org.wiremock:wiremockinpom.xml(test scope) — verify coordinateorg.wiremock:wiremock(not the retiredcom.github.tomakehurstgroupId)PersonService.findByDisplayNameContaining()withPersonServiceTest(red → green)3.5.
docs/architecture/c4/l3-backend-search.puml— create the C4 L3 diagram for the search domain while the architecture is fresh. Follow the naming convention of existingl3-backend-*.pumlfiles indocs/architecture/c4/.NlQueryInterpretation,PersonHint,NlSearchResponse,OllamaClient,OllamaHealthClient)OllamaProperties+NlSearchRateLimiter+NlSearchRateLimitProperties+ config yaml entriesRestClientOllamaClientwithRestClientOllamaClientTest(WireMock)NlQueryParserServicewithNlQueryParserServiceTest(Mockito)NlSearchControllerwithNlSearchControllerTest(@WebMvcTest)DocumentRepository.findBySenderOrReceiver+DocumentService.searchDocumentsByPersonId+ integration testFactory helpers — write these before the first test:
makePersonHint(UUID id, String displayName)— builds aPersonHintrecordmakePerson(String firstName, String lastName)— builds aPersonentitymakeOllamaResponseJson(String... names)— forRestClientOllamaClientTestWireMock stubsmakeInterpretation(...)— for controller test stubsWriting these helpers first prevents 80% of test boilerplate duplication across the 40+ test cases in the plan.
JaCoCo gate: 77% (not 88% — backend README states 88% as an aspirational target;
pom.xmlgates at0.77).NlQueryParserServicehas many branches and its test plan covers all permutations — it will push coverage up, not threaten the gate.NlQueryParserServiceTest(@ExtendWith(MockitoExtension.class)) — unit tests for service logic; no Spring context.NlSearchControllerTest(@WebMvcTest(NlSearchController.class)+@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})) — controller-layer tests; uses@MockitoBean OllamaClient(not deprecated@MockBean). CallrateLimiter.resetForTest()in@BeforeEachto prevent bucket state from leaking between test methods. Package placement:NlSearchControllerTestmust be inorg.raddatz.familienarchiv.search(not a test-specific subpackage) to access the package-privateresetForTest()method.RestClientOllamaClientTest(realWireMockServer) — tests HTTP client behaviour: timeout, error responses, JSON parsing.Test plan —
NlQueryParserServiceTest:PersonHintinresolvedPersonsambiguousPersonsnon-empty, search result is emptypersonRole: "any"→ same as multi-match: ambiguousPersons non-empty, search does NOT execute (explicit test name:should_not_execute_search_when_name_is_ambiguous_even_if_personRole_is_any)personRole: "any"(single match) →searchDocumentsByPersonIdoverload is called; verifykeywordsApplied == falsein returnedNlQueryInterpretationsearchDocuments(senderId=person1, receiverId=person2)called; positional assertion:resolvedPersons.get(0)is the sender candidate andresolvedPersons.get(1)is the receiver candidate — assert by index, not just presence in listDocumentServiceis never called (zero invocations); first person inresolvedPersons, second name's candidates inambiguousPersonspersonNames = ["Walter", "Emma", "Heinrich"], Walter and Emma both resolve → assertDocumentService.searchDocuments(senderId=Walter, receiverId=Emma, text="Heinrich", ...)called; "Heinrich" is in the space-joined keyword string passed as the text argumentkeywords=["Krieg", "Walter"]→ assertdocumentService.searchDocuments(eq("Krieg Walter"), ...)called — pinsString.join(" ", keywords)behavior. Write as a standalone test, not part of a larger happy-path test.LocalDatefrom/to mappingnull, not folded into keywordsVALIDATION_ERRORbefore calling OllamaVALIDATION_ERRORpersonNames/keywordsfields → null-coalesced to empty list, no NPEpersonRole→ defaults to"any", logs warningSMART_SEARCH_UNAVAILABLEPersonRepositorycallTest plan —
NlSearchControllerTest(@WebMvcTest):OllamaClient; assertNlQueryInterpretation.resolvedPersons,ambiguousPersons,keywords,dateFrom,dateTo,keywordsAppliedin response bodyambiguousPersonsresponse: verifyPersonHintshape (id + displayName)VALIDATION_ERROR@MockitoBean OllamaClientreturns error → 503 withSMART_SEARCH_UNAVAILABLESMART_SEARCH_RATE_LIMITED; use@WithMockUser(username = "testuser", authorities = {"READ_ALL"})— works directly since rate limiter is keyed on the email stringTest plan —
RestClientOllamaClientTest(WireMock):NlQueryInterpretationSMART_SEARCH_UNAVAILABLEtimeoutSeconds) →SMART_SEARCH_UNAVAILABLESMART_SEARCH_UNAVAILABLE(not a parse exception escaping to the controller). Stub must includeContent-Type: application/json— without it Jackson may not attempt parsing and the error path differs.Test plan —
DocumentRepository.findBySenderOrReceiverintegration test (Testcontainers, real Postgres):Infrastructure notes (for infra issue)
When the Ollama Compose service is defined:
ollama/ollama:0.5.x) — not:latest; add to Renovate config (same pattern as existing services inrenovate.json)ollama_models:— persists the downloaded model across restartsexpose: ["11434"]notports:— internal network only, never Caddy-routed. Hard requirement: the Ollama inference API has no authentication by default;ports:would expose it to the host network, allowing arbitrary model inference by anyone who can reach the host.GET http://localhost:11434/api/tags;start_period: 120sfor model loading (weight loading from SSD takes 20–60 s on the current hardware; 120 s provides ample margin)ollama pull qwen2.5:7b-instruct-q4_K_M— must complete before backend starts or backend will 503 on all NL search requests. Add as explicit checklist in DEPLOYMENT.md: (1)docker compose up -d ollama; (2) pull model (allow 10–30 min, ~4.5 GB):docker exec <ollama-container> ollama pull qwen2.5:7b-instruct-q4_K_M; (3) verify:curl http://localhost:11434/api/tags; (4)docker compose up -d backend.backend.depends_onon Ollama healthcheck: declaredepends_on: ollama: condition: service_healthyto prevent a boot-time 503 storm. Note:service_healthyconfirms Ollama is responding to/api/tags— it does NOT confirm the model is downloaded. If theollama pullstep was skipped, inference returns 404 and all NL search requests 503 silently until the pull completes.ollama_modelsto the backup exclusion list in the backup runbook — model weights are re-downloadable from the Ollama registry and are not user data; they must not be included inpg_dumpor Hetzner S3 backup flowsDocumentation
search/to the CLAUDE.md package structure tabledocs/architecture/c4/l3-backend-search.puml— follow the naming convention of existingl3-backend-*.pumlfiles; create in commit 3.5 (after ADR, before domain records)docs/architecture/c4/l2-containers.puml(new Ollama container)docs/architecture/c4/l1-context.puml(new external system: Ollama)SMART_SEARCH_UNAVAILABLE,SMART_SEARCH_RATE_LIMITEDtoCLAUDE.mdanddocs/ARCHITECTURE.mdNlSearch,NlQueryInterpretation,PersonHinttodocs/GLOSSARY.mddocs/DEPLOYMENT.md(model pre-pull runbook from Infrastructure notes above, volume management, update procedure)Response records
All fields the backend always populates need
@Schema(requiredMode = REQUIRED). Runnpm run generate:apiin the same PR.Frontend contract note:
ambiguousPersonsnon-empty → disambiguation UI, results suppressed.resolvedPersonsnon-empty (andambiguousPersonsempty) → interpretation chip shown. Both empty → keyword/date-only search. For 2-name queries,resolvedPersons[0]= sender,resolvedPersons[1]= receiver — the chip must render the directionality ("Walter → Emma"), not just the names. WhenkeywordsApplied == false(single-namepersonRole: "any"queries), keyword chips must NOT be shown — keywords were parsed but not used to filter results.Frontend UX notes (for the frontend issue):
keywordsApplied == falserendering: do not silently omit the parsed keywords. Show a secondary text line below the interpretation chip using the i18n keysmart_search_keywords_not_applied. Silent omission confuses 60+ users who said "Krieg" and see no explanation for why it was ignored.Disambiguation UI: the frontend issue must specify an interaction pattern. A modal with large text and a clear confirmation button is recommended for the 60+ audience — inline DOM changes below the search bar are less disorienting but harder to discover.
ambiguousPersonscontains at most 10 candidates (capped inNlQueryParserService). Either way, the pattern must be decided before writing the frontend issue.Loading state (2–15 seconds): use
aria-live="polite"with a persistent, non-dismissable "Suche läuft…" message. Do not use a toast or auto-dismissing spinner — the search takes up to 15 seconds and users need continuous feedback. Test with axe-playwright before marking the frontend issue done.Timeout fallback CTA (30 seconds): when
SMART_SEARCH_UNAVAILABLE(503) arrives after the timeout, show an actionable message with a link to the regular search: de: "Die Suche hat zu lange gedauert — bitte versuche es noch einmal oder nutze die normale Suche." Include this as an acceptance criterion in the frontend issue.Acceptance Criteria
POST /api/search/nlwith{"query": "Was hat walter im krieg geschrieben?"}returns aNlSearchResponsecontaining matching documents and interpretation chipssearchDocuments(text="Briefe aus dem Krieg", sender=null, receiver=null, from=null, to=null, ...)— sender and receiver are null["Schmidt"]but no Schmidts exist) returns documents filtered by keywords and date range only, with the unmatched name(s) folded into the keywords listNlQueryInterpretation.ambiguousPersonsasPersonHintobjects (id + displayName, up to 10 candidates) — the frontend must prompt the user to disambiguatepersonRoleis"any"and the name resolves to exactly one person returns documents where that person is sender OR receiver (viasearchDocumentsByPersonId);keywordsAppliedisfalsein the responsepersonRoleis"any"and the name is ambiguous behaves identically to the regular ambiguous case — empty result +ambiguousPersonslist; disambiguation takes priority regardless ofpersonRoleresolvedPersonsand Emma's candidates inambiguousPersons— user must pick before search proceedsSMART_SEARCH_UNAVAILABLEVALIDATION_ERRORSMART_SEARCH_RATE_LIMITEDGET /api/documents/searchbehaviour is unchangedNlQueryInterpretation.dateFromanddateToare serialized as ISO-8601 date strings ("1914-01-01") when present, null when absentmarcel referenced this issue2026-06-06 15:32:17 +02:00
marcel referenced this issue2026-06-06 15:33:31 +02:00
marcel referenced this issue2026-06-06 15:46:03 +02:00
marcel referenced this issue2026-06-06 15:47:11 +02:00
marcel referenced this issue2026-06-06 15:47:29 +02:00
marcel referenced this issue2026-06-06 16:03:02 +02:00
marcel referenced this issue2026-06-06 16:03:37 +02:00
marcel referenced this issue2026-06-06 16:04:13 +02:00
marcel referenced this issue2026-06-06 16:04:27 +02:00
Implementation complete ✅
Branch:
worktree-feat+issue-738-nl-search-backendCommits
feat(person): add findByDisplayNameContaining service method— one-liner wrapper delegating to existingPersonRepository.searchByName()docs(adr): ADR-028 — NL search via Ollama— covers Qwen 2.5 7B, grammar-constrained JSON, CPU-only inference, graceful degradationdocs(c4): add L3 backend search component diagram— all 8 search-package components + relations to PersonService, DocumentService, PostgreSQLfeat(search): add NL search domain records and OllamaClient interfaces— OllamaClient, OllamaHealthClient, PersonHint, NlQueryInterpretation, NlSearchResponse, NlSearchRequest, OllamaExtractionfeat(search): add NL search error codes and i18n strings— SMART_SEARCH_UNAVAILABLE (503), SMART_SEARCH_RATE_LIMITED (429) in ErrorCode.java, errors.ts, and all three message filesfeat(search): add Ollama and rate-limit config properties— OllamaProperties, NlSearchRateLimitProperties, application.yaml + application-dev.yamlfeat(search): add NlSearchRateLimiter with Bucket4j/Caffeine— 5 req/min per user, package-private resetForTest()feat(search): implement RestClientOllamaClient with WireMock tests— grammar-constrained POST /api/generate, 2 s health-check client, graceful degradation on timeout/500/malformed JSONfeat(search): implement NlQueryParserService with Mockito tests (23 cases)— full name resolution algorithm: single-match / ambiguous / no-match / role-based sender+receiver / 3+ names → extra fragments; keywordsApplied flag; 200-char name guard; 10-candidate capfeat(search): implement NlSearchController with @WebMvcTest tests (7 cases)— POST /api/search/nl, @RequirePermission(READ_ALL), rate limiter wired to principal.getUsername()feat(search): add searchDocumentsByPersonId with Specification-based sender/receiver query— avoids PostgreSQL null type-inference issue; DISTINCT via query.distinct(true); 5 DocumentRepository integration testsfeat(search): add @Schema annotations and regenerate TypeScript API types— NlSearchRequest, NlQueryInterpretation, NlSearchResponse, PersonHint now in generated api.tsdocs(search): update CLAUDE.md, GLOSSARY, DEPLOYMENT, and C4 diagrams— search/ package entry, Ollama runbook, C4 L1+L2 updatesTest results
NlQueryParserServiceTest— 23 tests ✅NlSearchControllerTest— 7 tests ✅RestClientOllamaClientTest— 4 tests ✅NlSearchRateLimiterTest— 4 tests ✅DocumentRepositoryTest— 34 tests (incl. 5 new person-spec tests) ✅What was built
POST /api/search/nlendpoint — natural language query → structuredNlSearchResponseRestClientOllamaClientwith grammar-constrained JSON schema (Qwen 2.5 7B)smart_search_keywords_not_appliedNotes
searchDocumentsByPersonIduses a JPA Specification instead of JPQL to avoid PostgreSQL's null parameter type-inference issue withIS NULL ORpatternsNlSearchRequest/NlSearchResponsetypes from the regeneratedapi.tsmarcel referenced this issue2026-06-06 18:27:48 +02:00
marcel referenced this issue2026-06-06 18:28:38 +02:00
marcel referenced this issue2026-06-06 18:29:08 +02:00
marcel referenced this issue2026-06-06 18:57:26 +02:00
marcel referenced this issue2026-06-06 18:57:59 +02:00
marcel referenced this issue2026-06-06 19:15:33 +02:00
marcel referenced this issue2026-06-06 19:15:56 +02:00
marcel referenced this issue2026-06-06 19:16:13 +02:00
marcel referenced this issue2026-06-06 19:16:31 +02:00
marcel referenced this issue2026-06-06 20:35:49 +02:00
marcel referenced this issue2026-06-06 20:36:41 +02:00
marcel referenced this issue2026-06-06 21:05:14 +02:00