feat(search): thread lang through NlSearchRequest → controller → NlQueryParserService → NlpClient

- NlSearchRequest gains @NotBlank @Pattern(regexp="de|en|es") lang field
- NlSearchController passes request.lang() to service
- NlQueryParserService.search signature: (String, String, Pageable); renames ollamaClient→nlpClient; removes redundant length guard (Bean Validation is enforcement point)
- application.yaml: replaces app.ollama.* with app.nlp.base-url; application-dev.yaml: points to localhost:8001
- frontend/documents/+page.svelte: sends lang: languageTag() in POST body

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-07 15:58:48 +02:00
parent 34387f2d59
commit 8bed0cc6e2
6 changed files with 15 additions and 20 deletions

View File

@@ -34,18 +34,13 @@ public class NlQueryParserService {
private static final int MIN_TAG_TERM = 3; private static final int MIN_TAG_TERM = 3;
private static final int MAX_RESOLVED_TAGS = 10; private static final int MAX_RESOLVED_TAGS = 10;
private final OllamaClient ollamaClient; private final NlpClient nlpClient;
private final PersonService personService; private final PersonService personService;
private final DocumentService documentService; private final DocumentService documentService;
private final TagService tagService; private final TagService tagService;
public NlSearchResponse search(String query, Pageable pageable) { public NlSearchResponse search(String query, String lang, Pageable pageable) {
if (query == null || query.length() < MIN_QUERY || query.length() > MAX_QUERY) { NlpExtraction ext = nlpClient.parse(query, lang);
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Query must be between " + MIN_QUERY + " and " + MAX_QUERY + " characters");
}
OllamaExtraction ext = ollamaClient.parse(query);
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();

View File

@@ -23,6 +23,6 @@ public class NlSearchController {
Pageable pageable, Pageable pageable,
@AuthenticationPrincipal UserDetails principal) { @AuthenticationPrincipal UserDetails principal) {
rateLimiter.checkAndConsume(principal.getUsername()); rateLimiter.checkAndConsume(principal.getUsername());
return nlQueryParserService.search(request.query(), pageable); return nlQueryParserService.search(request.query(), request.lang(), pageable);
} }
} }

View File

@@ -1,11 +1,15 @@
package org.raddatz.familienarchiv.search; package org.raddatz.familienarchiv.search;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
public record NlSearchRequest( public record NlSearchRequest(
@NotBlank @NotBlank
@Size(min = 3, max = 500) @Size(min = 3, max = 500)
String query String query,
@NotBlank
@Pattern(regexp = "de|en|es")
String lang
) { ) {
} }

View File

@@ -13,5 +13,5 @@ springdoc:
path: /swagger-ui.html path: /swagger-ui.html
app: app:
ollama: nlp:
base-url: http://localhost:11434 base-url: http://localhost:8001

View File

@@ -130,13 +130,8 @@ app:
# The loader maps columns by header name — no positional indices (see ADR-025). # The loader maps columns by header name — no positional indices (see ADR-025).
dir: ${IMPORT_DIR:/import} dir: ${IMPORT_DIR:/import}
ollama: nlp:
base-url: http://ollama:11434 base-url: http://nlp-service:8001
model: qwen2.5:7b-instruct-q4_K_M
# CPU inference: ~18s warm. Higher ceiling absorbs the cold model load on the
# first query after an Ollama (re)start before OLLAMA_KEEP_ALIVE pins it.
timeout-seconds: 60
health-check-timeout-seconds: 2
nl-search: nl-search:
rate-limit: rate-limit:

View File

@@ -17,6 +17,7 @@ import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
import { getErrorMessage, parseBackendError } from '$lib/shared/errors'; import { getErrorMessage, parseBackendError } from '$lib/shared/errors';
import { csrfFetch } from '$lib/shared/cookies'; import { csrfFetch } from '$lib/shared/cookies';
import * as m from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { languageTag } from '$lib/paraglide/runtime';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation']; type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
@@ -224,7 +225,7 @@ async function runSmartSearch() {
const res = await csrfFetch('/api/search/nl', { const res = await csrfFetch('/api/search/nl', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }) body: JSON.stringify({ query, lang: languageTag() })
}); });
if (!res.ok) { if (!res.ok) {
const backend = await parseBackendError(res); const backend = await parseBackendError(res);