From b665e1132d2a5d86131e151598c3df33035b2484 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 19:20:22 +0200 Subject: [PATCH 01/29] fix(infra): deploy Ollama to prod/staging compose + fix broken model-init recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NL search returned 503 (SMART_SEARCH_UNAVAILABLE / "Intelligente Suche nicht verfügbar") on staging because Ollama was never reachable. Two defects, both downstream of #737: 1. Ollama was added only to the dev docker-compose.yml. Staging/prod deploy from the self-contained docker-compose.prod.yml, which had no ollama service — so the backend (defaulting to http://ollama:11434) hit a non-existent host (ResourceAccessException -> 503). 2. The merged model-init recipe never worked: the ollama/ollama image ENTRYPOINT is `ollama` (so `command: sh -c ...` ran as `ollama sh ...` -> "unknown command sh"), and the image ships no curl (so both the readiness loop and the healthcheck could never pass). - docker-compose.prod.yml: add ollama-model-init + ollama services and the ollama-models volume, with the corrected recipe (entrypoint override to /bin/sh -c, `ollama list` for readiness and healthcheck). - docker-compose.yml: fix the same broken entrypoint/command and the curl healthcheck so the dev stack actually starts Ollama. Verified on staging end-to-end: model-init exits 0, ollama healthy, backend reaches /api/tags, inference succeeds within the 8g limit. Refs #758 Co-Authored-By: Claude Opus 4.8 --- docker-compose.prod.yml | 59 +++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 11 +++++--- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 26e07442..9aa8f80c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -50,6 +50,7 @@ volumes: minio-data: ocr-models: ocr-cache: + ollama-models: services: db: @@ -200,6 +201,64 @@ services: security_opt: - no-new-privileges:true + # --- Ollama: Model init (one-shot pull) --- + # Pulls qwen2.5:7b-instruct-q4_K_M (~4.7 GB) into the ollama-models volume on + # first start; exits quickly on subsequent starts (model already cached). + # The ollama/ollama image's ENTRYPOINT is `ollama` and the image ships WITHOUT + # curl, so the entrypoint is overridden to a shell and readiness is probed with + # `ollama list` (not curl). Backend degrades gracefully (503) if Ollama is absent. + ollama-model-init: + image: ollama/ollama:0.30.6 + restart: "no" + entrypoint: ["/bin/sh", "-c"] + command: + - "ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && ollama pull qwen2.5:7b-instruct-q4_K_M" + networks: + - archiv-net + volumes: + - ollama-models:/root/.ollama + mem_limit: 2g + read_only: true + tmpfs: + - /tmp:size=512m + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + + # --- Ollama: LLM inference server --- + # Serves the pre-pulled model for NL search inference. Backend reaches it at + # http://ollama:11434 (application.yaml default; no env override required). + # Healthcheck uses `ollama list` because the image has no curl. + ollama: + image: ollama/ollama:0.30.6 + restart: unless-stopped + expose: + - "11434" + networks: + - archiv-net + volumes: + - ollama-models:/root/.ollama + cpus: "${OLLAMA_CPU_LIMIT:-4.0}" + mem_limit: "${OLLAMA_MEM_LIMIT:-8g}" + memswap_limit: "${OLLAMA_MEM_LIMIT:-8g}" + read_only: true + tmpfs: + - /tmp:size=512m + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "ollama", "list"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + depends_on: + ollama-model-init: + condition: service_completed_successfully + backend: image: familienarchiv/backend:${TAG:-nightly} build: diff --git a/docker-compose.yml b/docker-compose.yml index 78ac969a..bd54432f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -161,8 +161,11 @@ services: - ALL security_opt: - no-new-privileges:true - command: > - sh -c "ollama serve & SERVE_PID=$$! && until curl -sf http://localhost:11434/api/tags; do sleep 1; done && ollama pull qwen2.5:7b-instruct-q4_K_M && kill $$SERVE_PID" + # The image ENTRYPOINT is `ollama`, so override it to a shell; the image has + # no curl, so readiness is probed with `ollama list` instead of a curl loop. + entrypoint: ["/bin/sh", "-c"] + command: + - "ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && ollama pull qwen2.5:7b-instruct-q4_K_M" # --- Ollama: LLM inference server --- # Serves the pre-pulled model for NL search inference. @@ -191,7 +194,9 @@ services: security_opt: - no-new-privileges:true healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"] + # `ollama list` hits the local API and exits non-zero if the server is + # down — used instead of curl, which the image does not ship. + test: ["CMD", "ollama", "list"] interval: 30s timeout: 10s retries: 5 From 9e97687d0fadcf14c46bb9c275b6a531345fc9ff Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 19:27:02 +0200 Subject: [PATCH 02/29] fix(search): pin Ollama model in memory + raise read timeout NL search recovered after deploy but went 503 again after a few minutes: Ollama unloads the model after its default ~5 min keep-alive, so the next query cold-loads the 4.7 GB model and exceeds the backend's 30s read timeout (ResourceAccessException -> SMART_SEARCH_UNAVAILABLE). Warm inference is ~18s; the cold load after idle is what timed out. - docker-compose.{prod,yml}: set OLLAMA_KEEP_ALIVE=-1 on the ollama service so the model stays resident and never pays a cold-load penalty during normal operation (verified on staging: `ollama ps` -> UNTIL "Forever"; host has 47 GB free). - application.yaml: raise app.ollama.timeout-seconds 30 -> 60 so the one unavoidable cold load (first query after an Ollama restart, before the model is pinned) completes instead of timing out. Refs #758 Co-Authored-By: Claude Opus 4.8 --- backend/src/main/resources/application.yaml | 4 +++- docker-compose.prod.yml | 5 +++++ docker-compose.yml | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 36d5298a..ce517f25 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -133,7 +133,9 @@ app: ollama: base-url: http://ollama:11434 model: qwen2.5:7b-instruct-q4_K_M - timeout-seconds: 30 + # 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: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9aa8f80c..7008c41b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -239,6 +239,11 @@ services: - archiv-net volumes: - ollama-models:/root/.ollama + environment: + # Pin the model in memory (no idle unload). Without this, Ollama evicts + # the model after ~5 min idle and the next query pays a cold-load penalty + # that exceeds the backend read timeout → NL search 503 after idle. + OLLAMA_KEEP_ALIVE: "-1" cpus: "${OLLAMA_CPU_LIMIT:-4.0}" mem_limit: "${OLLAMA_MEM_LIMIT:-8g}" memswap_limit: "${OLLAMA_MEM_LIMIT:-8g}" diff --git a/docker-compose.yml b/docker-compose.yml index bd54432f..94df5bd7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -183,6 +183,9 @@ services: - ollama_models:/root/.ollama environment: OLLAMA_API_KEY: "${OLLAMA_API_KEY}" + # Pin the model in memory (no idle unload) so queries never pay a cold-load + # penalty that exceeds the backend read timeout → NL search 503 after idle. + OLLAMA_KEEP_ALIVE: "-1" cpus: "${OLLAMA_CPU_LIMIT:-4.0}" mem_limit: "${OLLAMA_MEM_LIMIT:-8g}" memswap_limit: "${OLLAMA_MEM_LIMIT:-8g}" From 2a0863cf3eb99e296bfaf4197c1b1fb28f31b704 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 20:08:55 +0200 Subject: [PATCH 03/29] docs(deploy): correct Ollama read timeout default to 60s application.yaml sets app.ollama.timeout-seconds: 60 (raised from 30 to absorb the cold model load on the first query after an Ollama restart), but DEPLOYMENT.md still documented 30. A doc that contradicts the shipped value is a traceability defect. Addresses #759 review (Markus, Felix, Elicit). Co-Authored-By: Claude Opus 4.8 --- docs/DEPLOYMENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 5c2580de..d5f6e1c1 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -613,7 +613,7 @@ Expected output includes `qwen2.5:7b-instruct-q4_K_M`. |---|---|---| | `app.ollama.base-url` | `http://ollama:11434` | Ollama service URL (dev: `http://localhost:11434`) | | `app.ollama.model` | `qwen2.5:7b-instruct-q4_K_M` | Model to use for inference | -| `app.ollama.timeout-seconds` | `30` | Read timeout for inference calls | +| `app.ollama.timeout-seconds` | `60` | Read timeout for inference calls (absorbs cold model load on the first query after an Ollama restart) | | `app.nl-search.rate-limit.max-requests-per-minute` | `5` | Per-user rate limit | ### Upgrade the Ollama model From f22a1a1cfa2c65f795beceb69bfad423e56d70d4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 20:09:48 +0200 Subject: [PATCH 04/29] docs(deploy): fix prod Ollama volume name to match hyphenated compose volume docker-compose.prod.yml declares the volume as `ollama-models` (hyphen), so the compose-project-prefixed name is `archiv-production_ollama-models`, not the underscored `archiv-production_ollama_models` the model-upgrade guide documented. The documented `docker volume rm` would not have matched the real volume. Addresses #759 review (Tobias #2). Co-Authored-By: Claude Opus 4.8 --- docs/DEPLOYMENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index d5f6e1c1..f8523515 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -625,7 +625,7 @@ To switch to a newer model version (e.g. a future release of `qwen2.5`): ```bash docker volume rm familienarchiv_ollama_models ``` - (In production the volume name is prefixed with the compose project: `archiv-production_ollama_models`.) + (In production the volume name is prefixed with the compose project: `archiv-production_ollama-models`.) 3. Restart the stack: ```bash docker compose up -d From a2f37f85a6bdbba520b5ad665e4e9ba7d6b64877 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 20:12:21 +0200 Subject: [PATCH 05/29] fix(infra): make prod Ollama model-init offline-safe The init command unconditionally ran `ollama pull`, which contacts the registry to verify the manifest digest even when the model is already on the volume. A host reboot during a registry/upstream-network blip would then fail init non-zero, the `service_completed_successfully` gate would never be met, and the ollama service (hence NL search) would stay down until the registry was reachable again. Guard the pull with `ollama list | grep -q ` so a cached model exits clean without any registry round-trip. Addresses #759 review (Tobias #1). Co-Authored-By: Claude Opus 4.8 --- docker-compose.prod.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7008c41b..9c60b3bf 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -206,13 +206,17 @@ services: # first start; exits quickly on subsequent starts (model already cached). # The ollama/ollama image's ENTRYPOINT is `ollama` and the image ships WITHOUT # curl, so the entrypoint is overridden to a shell and readiness is probed with - # `ollama list` (not curl). Backend degrades gracefully (503) if Ollama is absent. + # `ollama list` (not curl). The pull is guarded by a `grep` on the cached model + # list so a model already on the volume exits clean WITHOUT a registry round-trip + # — a host reboot during a registry/network blip can no longer fail init (which + # would block the ollama service via service_completed_successfully). + # Backend degrades gracefully (503) if Ollama is absent. ollama-model-init: image: ollama/ollama:0.30.6 restart: "no" entrypoint: ["/bin/sh", "-c"] command: - - "ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && ollama pull qwen2.5:7b-instruct-q4_K_M" + - "ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && (ollama list | grep -q 'qwen2.5:7b-instruct-q4_K_M' || ollama pull qwen2.5:7b-instruct-q4_K_M)" networks: - archiv-net volumes: From d7d6d0638ca8ab6e481313cf86dd20af1bce2096 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 20:13:19 +0200 Subject: [PATCH 06/29] fix(infra): make dev Ollama model-init offline-safe Mirror the prod hardening in the dev stack: guard the model pull with `ollama list | grep -q ` so an already-cached model exits clean without a registry round-trip. Keeps dev and prod on one recipe. Addresses #759 review (Tobias #1). Co-Authored-By: Claude Opus 4.8 --- docker-compose.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 94df5bd7..f9e618ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -163,9 +163,11 @@ services: - no-new-privileges:true # The image ENTRYPOINT is `ollama`, so override it to a shell; the image has # no curl, so readiness is probed with `ollama list` instead of a curl loop. + # The pull is guarded by a `grep` on the cached model list so an already-cached + # model exits clean without a registry round-trip (offline-safe re-up). entrypoint: ["/bin/sh", "-c"] command: - - "ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && ollama pull qwen2.5:7b-instruct-q4_K_M" + - "ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && (ollama list | grep -q 'qwen2.5:7b-instruct-q4_K_M' || ollama pull qwen2.5:7b-instruct-q4_K_M)" # --- Ollama: LLM inference server --- # Serves the pre-pulled model for NL search inference. From db87a64cc0c13b6d76780a1322e8aa85a6e29d4d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 20:14:26 +0200 Subject: [PATCH 07/29] docs(c4): de-duplicate Ollama container in l2-containers diagram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diagram declared Container(ollama, ...) twice — an alias collision that renders a duplicate box. It also declared the backend->ollama relationship twice. Keep the richer 'Ollama LLM Service' declaration and the more specific 'NL query parsing (POST /api/generate)' relationship; drop the duplicates. Addresses #759 review (Markus #2). Co-Authored-By: Claude Opus 4.8 --- docs/architecture/c4/l2-containers.puml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/architecture/c4/l2-containers.puml b/docs/architecture/c4/l2-containers.puml index 2d471dd9..b8630001 100644 --- a/docs/architecture/c4/l2-containers.puml +++ b/docs/architecture/c4/l2-containers.puml @@ -17,7 +17,6 @@ System_Boundary(archiv, "Familienarchiv (Docker Compose)") { ContainerDb(db, "Relational Database", "PostgreSQL 16", "Stores document metadata, persons, users, permission groups, tags, transcription blocks, audit log, and Spring Session data.") ContainerDb(storage, "Object Storage", "MinIO (S3-compatible)", "Stores the actual document files (PDFs, scans). Backend uses a bucket-scoped service account (archiv-app), not MinIO root.") Container(mc, "Bucket / Service-Account Init", "MinIO Client (mc)", "One-shot container on startup. Idempotent: creates the archive bucket, the archiv-app service account, and attaches the readwrite policy.") - Container(ollama, "Ollama", "Ollama / port 11434", "Local LLM inference server. Hosts qwen2.5:7b-instruct-q4_K_M for natural-language query parsing (NL Search). CPU-only; GPU not required.") } System_Boundary(observability, "Observability Stack (/opt/familienarchiv/docker-compose.observability.yml)") { @@ -49,7 +48,6 @@ Rel(promtail, loki, "Pushes log streams", "HTTP/Loki push API") Rel(backend, tempo, "Sends distributed traces via OTLP", "HTTP / OTLP / port 4318 (archiv-net)") Rel(prometheus, backend, "Scrapes JVM + HTTP metrics", "HTTP 8081 /actuator/prometheus") Rel(prometheus, ocr, "Scrapes OCR + http_* metrics", "HTTP 8000 /metrics") -Rel(backend, ollama, "NL search inference requests", "HTTP / REST / JSON") Rel(prometheus, ollama, "Scrapes LLM request metrics", "HTTP 11434 /metrics") Rel(grafana, prometheus, "Queries metrics", "HTTP 9090") Rel(grafana, loki, "Queries logs", "HTTP 3100") From ed98729f7502cfee7b6032da9186cf642c133db8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 20:16:03 +0200 Subject: [PATCH 08/29] docs(adr): record prod Ollama deployment + keep-alive decision (ADR-034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the why behind deploying Ollama to prod/staging compose: the corrected init recipe (supersedes ADR-028 §10's never-functional curl loop), the OLLAMA_KEEP_ALIVE=-1 pin (so a future maintainer doesn't optimize it away and reintroduce the post-idle cold-load 503), the 30->60s timeout NFR, and the memswap==mem hard-OOM trade-off. Addresses #759 review (Markus #3, Nora #2). Co-Authored-By: Claude Opus 4.8 --- ...ma-production-deployment-and-keep-alive.md | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/adr/034-ollama-production-deployment-and-keep-alive.md diff --git a/docs/adr/034-ollama-production-deployment-and-keep-alive.md b/docs/adr/034-ollama-production-deployment-and-keep-alive.md new file mode 100644 index 00000000..0ff4a790 --- /dev/null +++ b/docs/adr/034-ollama-production-deployment-and-keep-alive.md @@ -0,0 +1,125 @@ +# ADR-034: Ollama in production — deployment, keep-alive pinning, and corrected init recipe + +**Date:** 2026-06-06 +**Status:** Accepted +**Deciders:** Marcel Raddatz +**Relates to:** #758 (bug), #759 (fix), #737 (NL search infrastructure) +**Corrects:** ADR-028 §10–§11 (init recipe and readiness probe) + +--- + +## Context + +ADR-028 introduced Ollama as a Docker Compose service for NL search and documented +its topology, graceful-degradation contract, and memory budget. Two defects survived +that work and only surfaced once NL search reached staging (#758): + +1. **Ollama was added only to the dev `docker-compose.yml`.** Staging and production + deploy from the self-contained `docker-compose.prod.yml`, which had no `ollama` + service. The backend defaults to `app.ollama.base-url: http://ollama:11434`, so its + client bean was active and resolved to a non-existent host → `ResourceAccessException` + → HTTP 503 on every NL search. +2. **The init recipe documented in ADR-028 §10 never worked.** The `ollama/ollama` image + `ENTRYPOINT` is `ollama`, so a bare `command: sh -c "…"` ran as `ollama sh -c "…"` + (`unknown command "sh"`), and the image ships **no curl**, so the curl-based readiness + loop and the curl healthcheck could never pass. + +This ADR records the production deployment decision and the corrected operational +contract. It is also the durable record of *why* `OLLAMA_KEEP_ALIVE=-1` is set, so a +future maintainer does not "optimize" it away and reintroduce the cold-load 503. + +--- + +## Decisions + +### 1. Ollama is a first-class production service + +`docker-compose.prod.yml` now defines `ollama` + `ollama-model-init` + the +`ollama-models` volume, mirroring the dev stack. The graceful-degradation contract from +ADR-028 §3 is preserved: `backend` has **no** hard `depends_on` on `ollama`, so an absent +or unhealthy Ollama still yields a clean 503 rather than blocking backend startup. + +### 2. Corrected init recipe (supersedes ADR-028 §10) + +The init container overrides the image entrypoint to a shell and probes readiness with +`ollama list` (not curl, which the image lacks): + +```sh +ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && \ + (ollama list | grep -q 'qwen2.5:7b-instruct-q4_K_M' || ollama pull qwen2.5:7b-instruct-q4_K_M) +``` + +```yaml +entrypoint: ["/bin/sh", "-c"] +``` + +The pull is **guarded by a grep on the cached model list**. A model already on the volume +exits clean without any registry round-trip. This makes re-up offline-safe: a host reboot +during a registry/network blip can no longer fail init (which, via +`condition: service_completed_successfully`, would otherwise block the `ollama` service +and take NL search down until the registry was reachable again). The same recipe is used +in dev and prod — one mental model. + +### 3. Healthcheck uses `ollama list` (supersedes ADR-028 §11 probe) + +```yaml +healthcheck: + test: ["CMD", "ollama", "list"] +``` + +`ollama list` hits the local API and exits non-zero when the server is down — the correct +probe for a curl-less image. The `start_period: 60s` rationale from ADR-028 §11 still holds. + +### 4. `OLLAMA_KEEP_ALIVE=-1` — pin the model in memory + +```yaml +environment: + OLLAMA_KEEP_ALIVE: "-1" +``` + +By default Ollama evicts an idle model after ~5 minutes. The next query then pays a +cold-load penalty that exceeds the backend read timeout, producing an NL search 503 after +any idle period. Pinning the model (`-1` = never unload) keeps warm-path latency +predictable (~18 s on CPU). **Do not remove this** without re-introducing the post-idle +cold-load 503. + +### 5. Read timeout raised 30 → 60 s + +`app.ollama.timeout-seconds` is raised from 30 to 60 (`application.yaml`, mirrored in +`DEPLOYMENT.md`). Warm CPU inference is ~18 s; the higher ceiling absorbs the one cold +model load on the first query after an Ollama (re)start, before §4's pin takes hold. + +**Implicit NFR made explicit:** NL search shall return a result or a 503 within 60 s; the +cold-start path immediately after an Ollama restart is the only path that approaches this +ceiling. + +### 6. Hard-OOM trade-off (refines ADR-028 §2) + +`memswap_limit == mem_limit` (both `${OLLAMA_MEM_LIMIT:-8g}`) disables swap for the +container. Combined with §4's pinned model, a memory-pressure event is a **hard OOM-kill, +not graceful latency degradation**. This is deliberate — swap-thrashing an LLM is worse +than a clean restart — but it means the 8 GB envelope is a real ceiling. `qwen2.5-7B-q4` +plus its KV cache under load sits close enough to 8 GB that this needs a Prometheus +memory alert on the `ollama` container before it bites in production (tracked as +observability follow-up, not in this PR). + +--- + +## Consequences + +### Positive + +- NL search works on staging/production, not just dev — the actual deploy artifact now + matches the documented architecture. +- Re-up is offline-safe: a cached model never depends on registry reachability. +- The keep-alive pin and timeout ceiling make NL search latency predictable on CPU. + +### Risks and operational implications + +- **Hard OOM under memory pressure** (§6): a Prometheus alert on `ollama` container memory + is required before this is load-bearing in prod. Tracked as an observability follow-up. +- **Unauthenticated inference** relies entirely on `archiv-net` isolation (ADR-028 §7/§12, + unchanged). Sending an `Authorization` header from `RestClientOllamaClient` is a separate + durable hardening item, tracked outside this PR. +- ADR-028 §10–§11 describe a recipe that never functioned; this ADR is the authoritative + init/healthcheck contract going forward. From 0f0d89702d6fb34a6ca2ce1b90087694f04434d0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 22:34:34 +0200 Subject: [PATCH 09/29] feat(search): add TagService.findByNameContaining for NL tag resolution Co-Authored-By: Claude Sonnet 4.6 --- .../org/raddatz/familienarchiv/tag/TagService.java | 4 ++++ .../raddatz/familienarchiv/tag/TagServiceTest.java | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/tag/TagService.java b/backend/src/main/java/org/raddatz/familienarchiv/tag/TagService.java index 0a077a2d..361d7d22 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/tag/TagService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/tag/TagService.java @@ -46,6 +46,10 @@ public class TagService { return enrichWithRelatives(matched); } + public List findByNameContaining(String fragment) { + return tagRepository.findByNameContainingIgnoreCase(fragment); + } + public Tag getById(UUID id) { return tagRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id)); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/tag/TagServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/tag/TagServiceTest.java index d0373f7b..3c097073 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/tag/TagServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/tag/TagServiceTest.java @@ -666,4 +666,17 @@ class TagServiceTest { // verify findAllById was called at least twice: once for extras, once inside resolveEffectiveColors verify(tagRepository, atLeastOnce()).findAllById(any()); } + + // ─── findByNameContaining ───────────────────────────────────────────────── + + @Test + void findByNameContaining_delegatesToRepository() { + Tag krieg = Tag.builder().id(UUID.randomUUID()).name("Krieg").build(); + when(tagRepository.findByNameContainingIgnoreCase("krieg")).thenReturn(List.of(krieg)); + + List result = tagService.findByNameContaining("krieg"); + + assertThat(result).containsExactly(krieg); + verify(tagRepository).findByNameContainingIgnoreCase("krieg"); + } } From 4e0ebc72c815e2596f24ca9bd11e5245b2cec73a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 22:35:24 +0200 Subject: [PATCH 10/29] feat(search): add TagHint record for NL tag resolution API surface Co-Authored-By: Claude Sonnet 4.6 --- .../org/raddatz/familienarchiv/search/TagHint.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/search/TagHint.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/TagHint.java b/backend/src/main/java/org/raddatz/familienarchiv/search/TagHint.java new file mode 100644 index 00000000..c488796f --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/TagHint.java @@ -0,0 +1,14 @@ +package org.raddatz.familienarchiv.search; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.UUID; + +public record TagHint( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + UUID id, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String name, + String color +) { +} From 5a09cd4cb4ef7b0dfe935d50f9f347e68c760f02 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 22:37:45 +0200 Subject: [PATCH 11/29] feat(search): extend NlQueryInterpretation with resolvedTags + tagsApplied Positional record fields added; all 3 construction sites updated with neutral defaults; NlQueryParserService wired for TagService (4th constructor arg); NlQueryParserServiceTest and NlSearchControllerTest synced. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/search/NlQueryInterpretation.java | 6 +++++- .../familienarchiv/search/NlQueryParserService.java | 12 +++++++++--- .../search/NlQueryParserServiceTest.java | 6 +++++- .../search/NlSearchControllerTest.java | 4 ++-- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryInterpretation.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryInterpretation.java index 5313f093..37611488 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryInterpretation.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryInterpretation.java @@ -15,8 +15,12 @@ public record NlQueryInterpretation( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List keywords, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List resolvedTags, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String rawQuery, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - boolean keywordsApplied + boolean keywordsApplied, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + boolean tagsApplied ) { } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java index 5938fb5e..4bf043e8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java @@ -10,12 +10,15 @@ import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.PersonService; +import org.raddatz.familienarchiv.tag.Tag; import org.raddatz.familienarchiv.tag.TagOperator; +import org.raddatz.familienarchiv.tag.TagService; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import java.time.LocalDate; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.UUID; @@ -28,10 +31,13 @@ public class NlQueryParserService { private static final int MAX_QUERY = 500; private static final int MAX_NAME_LENGTH = 200; private static final int MAX_CANDIDATES = 10; + private static final int MIN_TAG_TERM = 3; + private static final int MAX_RESOLVED_TAGS = 10; private final OllamaClient ollamaClient; private final PersonService personService; private final DocumentService documentService; + private final TagService tagService; public NlSearchResponse search(String query, Pageable pageable) { if (query == null || query.length() < MIN_QUERY || query.length() > MAX_QUERY) { @@ -50,7 +56,7 @@ public class NlQueryParserService { NlQueryInterpretation interpretation = new NlQueryInterpretation( List.of(), resolution.ambiguous(), ext.dateFrom(), ext.dateTo(), - keywords, ext.rawQuery(), false); + keywords, List.of(), ext.rawQuery(), false, false); return new NlSearchResponse(DocumentSearchResult.of(List.of()), interpretation); } @@ -65,7 +71,7 @@ public class NlQueryParserService { DocumentSearchResult docs = documentService.searchDocumentsByPersonId( personId, ext.dateFrom(), ext.dateTo(), pageable); NlQueryInterpretation interpretation = new NlQueryInterpretation( - resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, ext.rawQuery(), false); + resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, List.of(), ext.rawQuery(), false, false); return new NlSearchResponse(docs, interpretation); } @@ -82,7 +88,7 @@ public class NlQueryParserService { 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, ext.rawQuery(), keywordsApplied); + resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, List.of(), ext.rawQuery(), keywordsApplied, false); return new NlSearchResponse(docs, interpretation); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java index 65d73685..aa79b94a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java @@ -13,7 +13,9 @@ import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.PersonService; +import org.raddatz.familienarchiv.tag.Tag; import org.raddatz.familienarchiv.tag.TagOperator; +import org.raddatz.familienarchiv.tag.TagService; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -32,6 +34,7 @@ class NlQueryParserServiceTest { @Mock OllamaClient ollamaClient; @Mock PersonService personService; @Mock DocumentService documentService; + @Mock TagService tagService; NlQueryParserService service; @@ -40,11 +43,12 @@ class NlQueryParserServiceTest { @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); - service = new NlQueryParserService(ollamaClient, personService, documentService); + service = new NlQueryParserService(ollamaClient, personService, documentService, tagService); when(documentService.searchDocuments(any(), any(), any(), any())) .thenReturn(DocumentSearchResult.of(List.of())); when(documentService.searchDocumentsByPersonId(any(), any(), any(), any())) .thenReturn(DocumentSearchResult.of(List.of())); + when(tagService.findByNameContaining(anyString())).thenReturn(List.of()); } // --- Factory helpers --- diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java index b35b1c52..c0d30f40 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java @@ -48,7 +48,7 @@ class NlSearchControllerTest { PersonHint hint = new PersonHint(UUID.randomUUID(), "Walter Raddatz"); NlQueryInterpretation interp = new NlQueryInterpretation( List.of(hint), List.of(), null, null, - List.of("Krieg"), "Briefe von Walter im Krieg", true); + List.of("Krieg"), List.of(), "Briefe von Walter im Krieg", true, false); return new NlSearchResponse(DocumentSearchResult.of(List.of()), interp); } @@ -77,7 +77,7 @@ class NlSearchControllerTest { PersonHint b = new PersonHint(UUID.randomUUID(), "Walter Schmidt"); NlQueryInterpretation interp = new NlQueryInterpretation( List.of(), List.of(a, b), null, null, - List.of(), "Briefe von Walter", false); + List.of(), List.of(), "Briefe von Walter", false, false); NlSearchResponse resp = new NlSearchResponse(DocumentSearchResult.of(List.of()), interp); when(nlQueryParserService.search(anyString(), any())).thenReturn(resp); From 39ff63921dcad40d20bf77fced733bfb81cb6b71 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 22:49:54 +0200 Subject: [PATCH 12/29] refactor(search): extract ChipType to chip-types.ts; audit NL fixtures Pre-implementation step for #743: ChipType union extracted from InterpretationChipRow and +page.svelte into shared chip-types.ts; resolvedTags/tagsApplied neutral defaults added to test fixtures. Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/nl-search.spec.ts | 4 +++- frontend/src/routes/documents/+page.svelte | 3 +-- frontend/src/routes/search/InterpretationChipRow.svelte | 3 ++- .../src/routes/search/InterpretationChipRow.svelte.spec.ts | 2 ++ frontend/src/routes/search/chip-types.ts | 1 + 5 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 frontend/src/routes/search/chip-types.ts diff --git a/frontend/e2e/nl-search.spec.ts b/frontend/e2e/nl-search.spec.ts index bd869582..65ede99f 100644 --- a/frontend/e2e/nl-search.spec.ts +++ b/frontend/e2e/nl-search.spec.ts @@ -13,8 +13,10 @@ const interpretation = { dateFrom: '1914-01-01', dateTo: '1918-12-31', keywords: ['krieg'], + resolvedTags: [{ id: '33333333-3333-3333-3333-333333333333', name: 'Weltkrieg', color: 'sage' }], rawQuery: 'Was hat Walter an Emma im Krieg geschrieben?', - keywordsApplied: true + keywordsApplied: true, + tagsApplied: true }; const nlResponse = { diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index b9a2ece5..5481e729 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -10,6 +10,7 @@ import BulkSelectionBar from '$lib/document/BulkSelectionBar.svelte'; import TimelineDensityFilter from '$lib/document/TimelineDensityFilter.svelte'; import SmartSearchStatus from '../search/SmartSearchStatus.svelte'; import InterpretationChipRow from '../search/InterpretationChipRow.svelte'; +import type { ChipType } from '../search/chip-types.js'; import DisambiguationPicker from '../search/DisambiguationPicker.svelte'; import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte'; import { getErrorMessage, parseBackendError } from '$lib/shared/errors'; @@ -269,8 +270,6 @@ function paramsFromInterpretation(interp: NlQueryInterpretation) { }; } -type ChipType = 'sender' | 'directional' | 'date' | 'keyword'; - function removeChip(type: ChipType, value?: string) { if (!nlInterpretation) return; const p = paramsFromInterpretation(nlInterpretation); diff --git a/frontend/src/routes/search/InterpretationChipRow.svelte b/frontend/src/routes/search/InterpretationChipRow.svelte index 8e9cdbc0..62a26c81 100644 --- a/frontend/src/routes/search/InterpretationChipRow.svelte +++ b/frontend/src/routes/search/InterpretationChipRow.svelte @@ -3,8 +3,9 @@ import { SvelteSet } from 'svelte/reactivity'; import { m } from '$lib/paraglide/messages.js'; import type { components } from '$lib/generated/api'; +import type { ChipType } from './chip-types.js'; + type NlQueryInterpretation = components['schemas']['NlQueryInterpretation']; -type ChipType = 'sender' | 'directional' | 'date' | 'keyword'; let { interpretation, diff --git a/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts b/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts index f3164389..59b82e0b 100644 --- a/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts +++ b/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts @@ -17,8 +17,10 @@ const makeInterpretation = ( resolvedPersons: [], ambiguousPersons: [], keywords: [], + resolvedTags: [], rawQuery: 'test', keywordsApplied: true, + tagsApplied: false, ...overrides }); diff --git a/frontend/src/routes/search/chip-types.ts b/frontend/src/routes/search/chip-types.ts new file mode 100644 index 00000000..d78c58b8 --- /dev/null +++ b/frontend/src/routes/search/chip-types.ts @@ -0,0 +1 @@ +export type ChipType = 'sender' | 'directional' | 'date' | 'keyword' | 'theme'; From dcd0e725a794b024151d4b33dae1d6b1c681585c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 22:54:33 +0200 Subject: [PATCH 13/29] =?UTF-8?q?feat(search):=20implement=20keyword?= =?UTF-8?q?=E2=86=92tag=20resolution=20in=20NlQueryParserService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../search/NlQueryParserService.java | 63 ++++++++++++++++--- .../search/NlQueryParserServiceTest.java | 37 +++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java index 4bf043e8..41d3dbd2 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java @@ -50,6 +50,11 @@ public class NlQueryParserService { List personNames = ext.personNames() != null ? ext.personNames() : List.of(); List keywords = ext.keywords() != null ? ext.keywords() : List.of(); + TagResolution tagResolution = resolveTags(keywords); + List resolvedTagHints = tagResolution.hints(); + List resolvedTagNames = tagResolution.tagNames(); + List remainingKeywords = tagResolution.remaining(); + NameResolution resolution = resolveNames(personNames); if (!resolution.ambiguous().isEmpty()) { @@ -64,31 +69,35 @@ public class NlQueryParserService { List noMatchFragments = resolution.noMatchFragments(); List 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 keywords) { + LinkedHashSet seen = new LinkedHashSet<>(); + List remaining = new ArrayList<>(); + + for (String kw : keywords) { + if (kw == null || kw.length() < MIN_TAG_TERM) { + remaining.add(kw); + continue; + } + List 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 capped = seen.size() > MAX_RESOLVED_TAGS + ? new ArrayList<>(seen).subList(0, MAX_RESOLVED_TAGS) + : new ArrayList<>(seen); + + tagService.resolveEffectiveColors(capped); + + List hints = capped.stream() + .map(t -> new TagHint(t.getId(), t.getName(), t.getColor())) + .toList(); + List tagNames = capped.stream().map(Tag::getName).toList(); + + return new TagResolution(hints, tagNames, remaining); + } + private String buildText(List keywords, List noMatchFragments, - List extraFragments, String rawQuery) { + List extraFragments, String rawQuery, boolean hadStructuredMatch) { List 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 noMatchFragments, List extraFragments ) {} + + private record TagResolution( + List hints, + List tagNames, + List remaining + ) {} } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java index aa79b94a..29eaf0f9 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java @@ -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 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(); + } } From 01df815bad5d546ef7c4c339777fc6109ae27e1b Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 22:57:17 +0200 Subject: [PATCH 14/29] test(search): add 11 tag-resolution test cases to NlQueryParserServiceTest Covers multi-tag match, no-match FTS fallback, mixed resolution, personRole bypass, cap at 10, short-keyword skip, dedup, rawQuery suppression when all keywords resolve, flag independence, colour propagation via resolveEffectiveColors, and colour=null when depth constraint prevents resolution. Co-Authored-By: Claude Sonnet 4.6 --- .../search/NlQueryParserServiceTest.java | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java index 29eaf0f9..d1e9c970 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java @@ -21,6 +21,7 @@ import org.springframework.data.domain.Pageable; import java.time.LocalDate; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.UUID; @@ -478,4 +479,201 @@ class NlQueryParserServiceTest { assertThat(resp.interpretation().tagsApplied()).isTrue(); assertThat(cap.getValue().text()).isNull(); } + + private static final UUID T2 = UUID.fromString("00000000-0000-0000-0001-000000000002"); + + // --- 25. Keyword matches multiple tags → all in resolvedTags, OR-union --- + + @Test + void search_keywordMatchesMultipleTags_allIncluded() { + Tag hochzeit1 = tag(T1, "Hochzeit Raddatz"); + Tag hochzeit2 = tag(T2, "Hochzeit Braun"); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit"))); + when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit1, hochzeit2)); + + NlSearchResponse resp = service.search("Briefe über Hochzeit", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().tags()).containsExactlyInAnyOrder("Hochzeit Raddatz", "Hochzeit Braun"); + assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR); + assertThat(resp.interpretation().resolvedTags()).hasSize(2); + } + + // --- 26. Keyword no tag match → stays as FTS text, resolvedTags empty --- + + @Test + void search_keywordNoTagMatch_staysAsFtsText() { + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost"))); + + NlSearchResponse resp = service.search("Feldpost Briefe", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().text()).contains("Feldpost"); + assertThat(cap.getValue().tags()).isEmpty(); + assertThat(resp.interpretation().resolvedTags()).isEmpty(); + assertThat(resp.interpretation().tagsApplied()).isFalse(); + } + + // --- 27. Mixed: one keyword resolves, one doesn't → tag filter + FTS text --- + + @Test + void search_mixedKeywords_oneResolves_oneStaysAsText() { + Tag hochzeit = tag(T1, "Hochzeit"); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit", "Feldpost"))); + when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); + + NlSearchResponse resp = service.search("Hochzeit und Feldpost", PAGE); + + ArgumentCaptor 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(cap.getValue().text()).contains("Feldpost"); + assertThat(resp.interpretation().resolvedTags()).hasSize(1); + assertThat(resp.interpretation().tagsApplied()).isTrue(); + } + + // --- 28. personRole=any + 1 person + resolvable keyword → personId search, tagsApplied=false --- + + @Test + void search_personRoleAny_singlePerson_resolvableKeyword_tagsAppliedFalse() { + Person walter = person(P1, "Walter", "Raddatz"); + Tag hochzeit = tag(T1, "Hochzeit"); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of("Walter"), "any", null, null, List.of("Hochzeit"))); + when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter)); + when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); + + NlSearchResponse resp = service.search("Briefe von Walter über Hochzeit", PAGE); + + verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE)); + verify(documentService, never()).searchDocuments(any(), any(), any(), any()); + assertThat(resp.interpretation().tagsApplied()).isFalse(); + assertThat(resp.interpretation().resolvedTags()).hasSize(1); + assertThat(resp.interpretation().resolvedTags().get(0).name()).isEqualTo("Hochzeit"); + } + + // --- 29. Cap: keyword matches > 10 tags → capped at 10 --- + + @Test + void search_keywordMatchesMoreThanMaxTags_cappedAtTen() { + List eleven = new ArrayList<>(); + for (int i = 0; i < 11; i++) { + eleven.add(tag(UUID.randomUUID(), "Thema " + i)); + } + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Thema"))); + when(tagService.findByNameContaining("Thema")).thenReturn(eleven); + + NlSearchResponse resp = service.search("Dokumente zum Thema", PAGE); + + assertThat(resp.interpretation().resolvedTags()).hasSize(10); + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().tags()).hasSize(10); + } + + // --- 30. Short keyword (< 3 chars) → skipped, not passed to TagService --- + + @Test + void search_shortKeyword_skippedByTagResolution() { + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("ab", "Krieg"))); + + service.search("ab Krieg", PAGE); + + verify(tagService, never()).findByNameContaining("ab"); + verify(tagService).findByNameContaining("Krieg"); + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().text()).contains("ab"); + } + + // --- 31. Dedup: same tag matched by two keywords → appears once --- + + @Test + void search_sameTagMatchedByTwoKeywords_deduplicatedToOne() { + Tag hochzeit = tag(T1, "Hochzeit"); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit", "hoch"))); + when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); + when(tagService.findByNameContaining("hoch")).thenReturn(List.of(hochzeit)); + + NlSearchResponse resp = service.search("Hochzeit hoch", PAGE); + + assertThat(resp.interpretation().resolvedTags()).hasSize(1); + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().tags()).hasSize(1); + } + + // --- 32. All keywords resolve → rawQuery fallback suppressed, text=null --- + + @Test + void search_allKeywordsResolveToTags_rawQueryFallbackSuppressed() { + Tag hochzeit = tag(T1, "Hochzeit"); + when(ollamaClient.parse(anyString())) + .thenReturn(new OllamaExtraction(List.of(), "any", null, null, List.of("Hochzeit"), "raw query text")); + when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); + + NlSearchResponse resp = service.search("Hochzeit", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().text()).isNull(); + assertThat(cap.getValue().tags()).containsExactly("Hochzeit"); + } + + // --- 33. Flag independence: keywordsApplied=false AND tagsApplied=true --- + + @Test + void search_allKeywordsResolveToTags_keywordsAppliedFalse_tagsAppliedTrue() { + 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("Hochzeit Briefe", PAGE); + + assertThat(resp.interpretation().keywordsApplied()).isFalse(); + assertThat(resp.interpretation().tagsApplied()).isTrue(); + } + + // --- 34. Color carried through from resolveEffectiveColors --- + + @Test + void search_tagHint_carriesColorSetByResolveEffectiveColors() { + Tag hochzeit = tag(T1, "Hochzeit"); + doAnswer(invocation -> { + Collection tags = invocation.getArgument(0); + tags.forEach(t -> t.setColor("sage")); + return null; + }).when(tagService).resolveEffectiveColors(any()); + 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("Hochzeit", PAGE); + + assertThat(resp.interpretation().resolvedTags().get(0).color()).isEqualTo("sage"); + } + + // --- 35. Color stays null when resolveEffectiveColors leaves it unset --- + + @Test + void search_tagHint_colorIsNull_whenNoColorResolved() { + 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("Hochzeit", PAGE); + + assertThat(resp.interpretation().resolvedTags().get(0).color()).isNull(); + } } From b825076733cde19995f3c19b1de5c5fc56041123 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 22:59:07 +0200 Subject: [PATCH 15/29] test(search): DataJpaTest for descendant-expansion via TagRepository Verifies the recursive CTE in findDescendantIdsByName expands a parent tag to include all child IDs, and that findByNameContainingIgnoreCase matches both parent and child names when the fragment appears in both. Co-Authored-By: Claude Sonnet 4.6 --- .../NlSearchTagResolutionIntegrationTest.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchTagResolutionIntegrationTest.java diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchTagResolutionIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchTagResolutionIntegrationTest.java new file mode 100644 index 00000000..bbe0a88b --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchTagResolutionIntegrationTest.java @@ -0,0 +1,56 @@ +package org.raddatz.familienarchiv.search; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.raddatz.familienarchiv.tag.Tag; +import org.raddatz.familienarchiv.tag.TagRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class NlSearchTagResolutionIntegrationTest { + + @Autowired + private TagRepository tagRepository; + + @Test + void findDescendantIdsByName_parentName_includesChildId() { + Tag parent = tagRepository.save(Tag.builder().name("Krieg").build()); + Tag child = tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build()); + + List ids = tagRepository.findDescendantIdsByName("Krieg"); + + assertThat(ids).containsExactlyInAnyOrder(parent.getId(), child.getId()); + } + + @Test + void findDescendantIdsByName_childName_returnsOnlyChild() { + Tag parent = tagRepository.save(Tag.builder().name("Krieg").build()); + Tag child = tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build()); + + List ids = tagRepository.findDescendantIdsByName("Weltkrieg"); + + assertThat(ids).containsExactly(child.getId()); + assertThat(ids).doesNotContain(parent.getId()); + } + + @Test + void findByNameContainingIgnoreCase_parentSubstring_matchesParentOnly() { + Tag parent = tagRepository.save(Tag.builder().name("Krieg").build()); + tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build()); + + List found = tagRepository.findByNameContainingIgnoreCase("Krieg"); + + assertThat(found).extracting(Tag::getName).containsExactlyInAnyOrder("Krieg", "Weltkrieg"); + } +} From aa1f6436cc9b34021dfcf971c90cec92997b6212 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 22:59:58 +0200 Subject: [PATCH 16/29] feat(i18n): add search_chip_theme_prefix to de/en/es message bundles Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + 3 files changed, 3 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 1315a8c7..5ea1b00a 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -42,6 +42,7 @@ "search_chip_sender": "Absender", "search_chip_date": "Zeitraum", "search_chip_keyword": "Stichwort", + "search_chip_theme_prefix": "Thema", "search_chip_directional_label": "Von {from} zu {to}, Filter entfernen", "search_disambiguation_trigger_label": "Mehrere Personen gefunden — zum Auswählen klicken", "search_disambiguation_cue": "(auswählen…)", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a1315bdc..be3af0ce 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -42,6 +42,7 @@ "search_chip_sender": "Sender", "search_chip_date": "Period", "search_chip_keyword": "Keyword", + "search_chip_theme_prefix": "Topic", "search_chip_directional_label": "From {from} to {to}, remove filter", "search_disambiguation_trigger_label": "Several people found — click to choose", "search_disambiguation_cue": "(choose…)", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 86f2c52e..c5fb2fc8 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -42,6 +42,7 @@ "search_chip_sender": "Remitente", "search_chip_date": "Período", "search_chip_keyword": "Palabra clave", + "search_chip_theme_prefix": "Tema", "search_chip_directional_label": "De {from} a {to}, eliminar filtro", "search_disambiguation_trigger_label": "Se encontraron varias personas — haga clic para elegir", "search_disambiguation_cue": "(elegir…)", From 7f3ad8ce892dae832a8200fbdb27672258f19829 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 23:01:11 +0200 Subject: [PATCH 17/29] feat(api): add TagHint schema and extend NlQueryInterpretation with resolvedTags/tagsApplied Manual update since Docker compose backend runs old build; regenerate with npm run generate:api once new backend is deployed. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/generated/api.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 33d0904c..20653696 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -1905,8 +1905,10 @@ export interface components { /** Format: date */ dateTo?: string; keywords: string[]; + resolvedTags: components["schemas"]["TagHint"][]; rawQuery: string; keywordsApplied: boolean; + tagsApplied: boolean; }; NlSearchResponse: { result: components["schemas"]["DocumentSearchResult"]; @@ -1917,6 +1919,12 @@ export interface components { id: string; displayName: string; }; + TagHint: { + /** Format: uuid */ + id: string; + name: string; + color?: string; + }; SearchMatchData: { transcriptionSnippet?: string; titleOffsets: components["schemas"]["MatchOffset"][]; From 87fd0f39bb91c8881fbd4412e54557b919bad10d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 23:33:53 +0200 Subject: [PATCH 18/29] feat(search): render removable theme chips in InterpretationChipRow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When tagsApplied is true, each resolvedTag renders as a 'Thema: Name' chip with optional inline color style from the tag's resolved color. Clicking × calls onRemoveChip('theme', tag.name). Co-Authored-By: Claude Sonnet 4.6 --- .../search/InterpretationChipRow.svelte | 53 ++++++++++++- .../InterpretationChipRow.svelte.spec.ts | 76 +++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/search/InterpretationChipRow.svelte b/frontend/src/routes/search/InterpretationChipRow.svelte index 62a26c81..2356a774 100644 --- a/frontend/src/routes/search/InterpretationChipRow.svelte +++ b/frontend/src/routes/search/InterpretationChipRow.svelte @@ -6,6 +6,7 @@ import type { components } from '$lib/generated/api'; import type { ChipType } from './chip-types.js'; type NlQueryInterpretation = components['schemas']['NlQueryInterpretation']; +type TagHint = components['schemas']['TagHint']; let { interpretation, @@ -19,7 +20,8 @@ type Chip = | { key: string; type: 'sender'; label: string } | { key: string; type: 'directional'; from: string; to: string } | { key: string; type: 'date'; label: string } - | { key: string; type: 'keyword'; value: string; label: string }; + | { key: string; type: 'keyword'; value: string; label: string } + | { key: string; type: 'theme'; tag: TagHint; label: string }; // Locally removed chips. The parent remounts this component (via {#key}) on every // new NL search, so this set never needs an explicit reset. @@ -36,9 +38,22 @@ function dateRangeLabel(from: string | undefined, to: string | undefined): strin return fromYear ?? toYear ?? ''; } +function tagColorStyle(color: string | undefined): string | undefined { + if (!color) return undefined; + return `background-color: var(--c-tag-${color}); border-left-color: var(--c-tag-${color})`; +} + const chips = $derived.by(() => { const list: Chip[] = []; - const { resolvedPersons, dateFrom, dateTo, keywords, keywordsApplied } = interpretation; + const { + resolvedPersons, + dateFrom, + dateTo, + keywords, + keywordsApplied, + resolvedTags, + tagsApplied + } = interpretation; if (resolvedPersons.length >= 2) { list.push({ @@ -74,6 +89,17 @@ const chips = $derived.by(() => { } } + if (tagsApplied) { + for (const tag of resolvedTags) { + list.push({ + key: 'theme:' + tag.id, + type: 'theme', + tag, + label: `${m.search_chip_theme_prefix()}: ${tag.name}` + }); + } + } + return list.filter((chip) => !removed.has(chip.key)); }); @@ -83,7 +109,13 @@ const showKeywordsNotApplied = $derived( function remove(chip: Chip) { removed.add(chip.key); - onRemoveChip(chip.type, chip.type === 'keyword' ? chip.value : undefined); + if (chip.type === 'keyword') { + onRemoveChip(chip.type, chip.value); + } else if (chip.type === 'theme') { + onRemoveChip(chip.type, chip.tag.name); + } else { + onRemoveChip(chip.type, undefined); + } } const nameSpan = 'sm:max-w-[12rem] max-w-[8rem] truncate'; @@ -113,6 +145,21 @@ const removeButton = + {:else if chip.type === 'theme'} + + {m.search_chip_theme_prefix()}: + {chip.tag.name} + + {:else} {chip.label} diff --git a/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts b/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts index 59b82e0b..9cefc628 100644 --- a/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts +++ b/frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts @@ -6,6 +6,7 @@ import type { components } from '$lib/generated/api'; type NlQueryInterpretation = components['schemas']['NlQueryInterpretation']; type PersonHint = components['schemas']['PersonHint']; +type TagHint = components['schemas']['TagHint']; afterEach(() => cleanup()); @@ -132,4 +133,79 @@ describe('InterpretationChipRow', () => { .element(page.getByRole('button', { name: new RegExp('Absender') })) .toBeInTheDocument(); }); + + // ── theme chips ───────────────────────────────────────────────────────────── + + const makeTag = (id: string, name: string, color?: string): TagHint => ({ id, name, color }); + + it('renders theme chips when tagsApplied is true', async () => { + const { container } = render(InterpretationChipRow, { + interpretation: makeInterpretation({ + resolvedTags: [makeTag('t1', 'Hochzeit')], + tagsApplied: true + }), + onRemoveChip: vi.fn() + }); + expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(1); + await expect.element(page.getByText(/Thema: Hochzeit/)).toBeInTheDocument(); + }); + + it('renders no theme chips when tagsApplied is false', async () => { + const { container } = render(InterpretationChipRow, { + interpretation: makeInterpretation({ + resolvedTags: [makeTag('t1', 'Hochzeit')], + tagsApplied: false + }), + onRemoveChip: vi.fn() + }); + expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(0); + }); + + it('renders exactly N theme chips for N resolved tags', async () => { + const { container } = render(InterpretationChipRow, { + interpretation: makeInterpretation({ + resolvedTags: [makeTag('t1', 'Krieg'), makeTag('t2', 'Hochzeit'), makeTag('t3', 'Familie')], + tagsApplied: true + }), + onRemoveChip: vi.fn() + }); + expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(3); + }); + + it('calls onRemoveChip with "theme" and tag name when × is clicked', async () => { + const onRemoveChip = vi.fn(); + render(InterpretationChipRow, { + interpretation: makeInterpretation({ + resolvedTags: [makeTag('t1', 'Hochzeit')], + tagsApplied: true + }), + onRemoveChip + }); + await page.getByRole('button', { name: /Thema: Hochzeit/ }).click(); + expect(onRemoveChip).toHaveBeenCalledWith('theme', 'Hochzeit'); + }); + + it('applies inline color style for a tag with a color', async () => { + const { container } = render(InterpretationChipRow, { + interpretation: makeInterpretation({ + resolvedTags: [makeTag('t1', 'Hochzeit', 'sage')], + tagsApplied: true + }), + onRemoveChip: vi.fn() + }); + const chip = container.querySelector('[data-chip-type="theme"]') as HTMLElement; + expect(chip.style.backgroundColor).toBeTruthy(); + }); + + it('omits color style for a tag with no color', async () => { + const { container } = render(InterpretationChipRow, { + interpretation: makeInterpretation({ + resolvedTags: [makeTag('t1', 'Hochzeit')], + tagsApplied: true + }), + onRemoveChip: vi.fn() + }); + const chip = container.querySelector('[data-chip-type="theme"]') as HTMLElement; + expect(chip.getAttribute('style')).toBeFalsy(); + }); }); From 2c909f49a85f52baf547e54bdc9dc5289daa0f09 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 23:40:33 +0200 Subject: [PATCH 19/29] feat(search): wire theme chip removal to URL navigation in +page.svelte Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/nl-search.spec.ts | 30 ++++++- frontend/src/routes/documents/+page.svelte | 6 ++ .../documents/theme-chip-removal.spec.ts | 85 +++++++++++++++++++ .../routes/documents/theme-chip-removal.ts | 26 ++++++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 frontend/src/routes/documents/theme-chip-removal.spec.ts create mode 100644 frontend/src/routes/documents/theme-chip-removal.ts diff --git a/frontend/e2e/nl-search.spec.ts b/frontend/e2e/nl-search.spec.ts index 65ede99f..210ad173 100644 --- a/frontend/e2e/nl-search.spec.ts +++ b/frontend/e2e/nl-search.spec.ts @@ -58,9 +58,10 @@ test.describe('NL (smart) search — happy path', () => { // Loading panel announced to screen readers. await expect(page.getByText(/Archiv wird befragt/)).toBeVisible(); - // Directional chip (Walter → Emma) + keyword chip render once the fixture resolves. + // Directional chip (Walter → Emma) + keyword chip + theme chip render once the fixture resolves. await expect(page.getByText('→')).toBeVisible(); await expect(page.getByText('Stichwort: krieg')).toBeVisible(); + await expect(page.getByText(/Thema:.*Weltkrieg/)).toBeVisible(); // Accessibility — light mode. const lightScan = await new AxeBuilder({ page }) @@ -82,4 +83,31 @@ test.describe('NL (smart) search — happy path', () => { await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/); await expect(page).toHaveURL(/receiverId=22222222-2222-2222-2222-222222222222/); }); + + test('removing the last theme chip drops tag/tagOp but keeps person params', async ({ page }) => { + await page.route('**/api/search/nl', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(nlResponse) + }); + }); + + await page.goto('/documents'); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: /Text/ }).click(); + + const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…'); + await input.fill('Was hat Walter an Emma im Krieg geschrieben?'); + await input.press('Enter'); + + await expect(page.getByText(/Thema:.*Weltkrieg/)).toBeVisible(); + + // Remove the single theme chip — URL must carry sender UUID but no tag/tagOp. + await page.getByRole('button', { name: 'Filter entfernen: Thema: Weltkrieg' }).click(); + await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/); + const url = page.url(); + expect(url).not.toMatch(/tag=/); + expect(url).not.toMatch(/tagOp=/); + }); }); diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 5481e729..7e836964 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -11,6 +11,7 @@ import TimelineDensityFilter from '$lib/document/TimelineDensityFilter.svelte'; import SmartSearchStatus from '../search/SmartSearchStatus.svelte'; import InterpretationChipRow from '../search/InterpretationChipRow.svelte'; import type { ChipType } from '../search/chip-types.js'; +import { buildThemeRemovalUrl } from './theme-chip-removal.js'; import DisambiguationPicker from '../search/DisambiguationPicker.svelte'; import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte'; import { getErrorMessage, parseBackendError } from '$lib/shared/errors'; @@ -284,6 +285,11 @@ function removeChip(type: ChipType, value?: string) { } else if (type === 'keyword' && value) { const remaining = nlInterpretation.keywords.filter((keyword) => keyword !== value); p.q = remaining.join(' '); + } else if (type === 'theme' && value) { + const url = buildThemeRemovalUrl(nlInterpretation, value); + resetNlState(); + goto(url, { keepFocus: true, noScroll: true }); + return; } applyResolvedAndSearch(p); } diff --git a/frontend/src/routes/documents/theme-chip-removal.spec.ts b/frontend/src/routes/documents/theme-chip-removal.spec.ts new file mode 100644 index 00000000..f39edec0 --- /dev/null +++ b/frontend/src/routes/documents/theme-chip-removal.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { buildThemeRemovalUrl } from './theme-chip-removal.js'; +import type { components } from '$lib/generated/api'; + +type NlQueryInterpretation = components['schemas']['NlQueryInterpretation']; + +function makeInterp(overrides: Partial = {}): NlQueryInterpretation { + return { + resolvedPersons: [], + ambiguousPersons: [], + keywords: [], + resolvedTags: [], + rawQuery: '', + keywordsApplied: false, + tagsApplied: true, + ...overrides + }; +} + +function makeTag(id: string, name: string, color?: string) { + return color ? { id, name, color } : { id, name }; +} + +describe('buildThemeRemovalUrl', () => { + it('N remaining tags → N tag params + tagOp=OR', () => { + const interp = makeInterp({ + resolvedTags: [ + makeTag('aaa', 'Hochzeit'), + makeTag('bbb', 'Weltkrieg'), + makeTag('ccc', 'Familie') + ] + }); + const url = buildThemeRemovalUrl(interp, 'Hochzeit'); + const params = new URL(url, 'http://x').searchParams; + expect(params.getAll('tag')).toEqual(['Weltkrieg', 'Familie']); + expect(params.get('tagOp')).toBe('OR'); + }); + + it('last tag removed → no tag or tagOp params in URL', () => { + const interp = makeInterp({ + resolvedTags: [makeTag('aaa', 'Hochzeit')] + }); + const url = buildThemeRemovalUrl(interp, 'Hochzeit'); + const params = new URL(url, 'http://x').searchParams; + expect(params.getAll('tag')).toEqual([]); + expect(params.get('tagOp')).toBeNull(); + }); + + it('last tag removed with resolved sender person → sender param intact', () => { + const interp = makeInterp({ + resolvedPersons: [{ id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter' }], + resolvedTags: [makeTag('aaa', 'Hochzeit')] + }); + const url = buildThemeRemovalUrl(interp, 'Hochzeit'); + const params = new URL(url, 'http://x').searchParams; + expect(params.get('senderId')).toBe('11111111-1111-1111-1111-111111111111'); + expect(params.getAll('tag')).toEqual([]); + expect(params.get('tagOp')).toBeNull(); + }); + + it('null-color tag → tag name emitted correctly; color does not affect params', () => { + const interp = makeInterp({ + resolvedTags: [makeTag('aaa', 'Erbschaft'), makeTag('bbb', 'Migration')] + }); + const url = buildThemeRemovalUrl(interp, 'Erbschaft'); + const params = new URL(url, 'http://x').searchParams; + expect(params.getAll('tag')).toEqual(['Migration']); + expect(params.get('tagOp')).toBe('OR'); + }); + + it('directional pair → senderId and receiverId both emitted', () => { + const interp = makeInterp({ + resolvedPersons: [ + { id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter' }, + { id: '22222222-2222-2222-2222-222222222222', displayName: 'Emma' } + ], + resolvedTags: [makeTag('aaa', 'Krieg'), makeTag('bbb', 'Heimat')] + }); + const url = buildThemeRemovalUrl(interp, 'Krieg'); + const params = new URL(url, 'http://x').searchParams; + expect(params.get('senderId')).toBe('11111111-1111-1111-1111-111111111111'); + expect(params.get('receiverId')).toBe('22222222-2222-2222-2222-222222222222'); + expect(params.getAll('tag')).toEqual(['Heimat']); + }); +}); diff --git a/frontend/src/routes/documents/theme-chip-removal.ts b/frontend/src/routes/documents/theme-chip-removal.ts new file mode 100644 index 00000000..21e7a511 --- /dev/null +++ b/frontend/src/routes/documents/theme-chip-removal.ts @@ -0,0 +1,26 @@ +import type { components } from '$lib/generated/api'; + +type NlQueryInterpretation = components['schemas']['NlQueryInterpretation']; + +export function buildThemeRemovalUrl( + interp: NlQueryInterpretation, + removedTagName: string +): string { + const remaining = interp.resolvedTags.filter((t) => t.name !== removedTagName); + const params = new URLSearchParams(); + + const resolved = interp.resolvedPersons; + if (resolved.length >= 1) params.set('senderId', resolved[0].id); + if (resolved.length >= 2) params.set('receiverId', resolved[1].id); + if (interp.dateFrom) params.set('from', interp.dateFrom); + if (interp.dateTo) params.set('to', interp.dateTo); + if (interp.keywordsApplied && interp.keywords.length > 0) { + params.set('q', interp.keywords.join(' ')); + } + + remaining.forEach((tag) => params.append('tag', tag.name)); + if (remaining.length > 0) params.set('tagOp', 'OR'); + + const qs = params.toString(); + return qs ? `/documents?${qs}` : '/documents'; +} From 0fe0ae52357a762e460e8224a7a06843d97a2df4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 23:42:23 +0200 Subject: [PATCH 20/29] docs(search): ADR-028 fix + glossary + C4 diagram for tag resolution (#743) Co-Authored-By: Claude Sonnet 4.6 --- docs/GLOSSARY.md | 8 +++++++- docs/adr/028-nl-search-ollama.md | 4 +++- docs/architecture/c4/l3-backend-3h-search.puml | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 1c6e8cb6..7ee1b565 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -171,10 +171,16 @@ _See also [Chronik](#chronik-internal)._ **NlSearch** — the natural-language document search feature. Users type a plain-German query (e.g. "Was hat Walter im Krieg an Emma geschrieben?"); the backend parses it via Ollama, resolves person names to database UUIDs, and delegates to the standard `DocumentService.searchDocuments()` path. Endpoint: `POST /api/search/nl`. -**NlQueryInterpretation** — the structured result of parsing a natural-language query. Contains: `resolvedPersons` (persons whose names unambiguously matched one DB record), `ambiguousPersons` (all candidates when a name matched more than one person), `keywords` (LLM-extracted search terms), `dateFrom`/`dateTo` (extracted date range), `rawQuery` (the original user input), and `keywordsApplied` (whether keyword FTS was used in the search). +**NlQueryInterpretation** — the structured result of parsing a natural-language query. Contains: `resolvedPersons` (persons whose names unambiguously matched one DB record), `ambiguousPersons` (all candidates when a name matched more than one person), `keywords` (LLM-extracted search terms), `dateFrom`/`dateTo` (extracted date range), `rawQuery` (the original user input), `keywordsApplied` (whether keyword FTS was used), `resolvedTags` (tags matched by keyword→tag resolution), and `tagsApplied` (whether the OR-union tag filter was applied). + +**keyword→tag resolution** — the post-Ollama step in `NlQueryParserService` where each LLM-extracted keyword is substring-matched against the tag taxonomy via `TagService.findByNameContaining()`. Keywords that hit one or more tags are removed from the FTS text list and become an OR-union tag filter; keywords with no match remain as FTS text. Matching is case-insensitive and traverses the tag hierarchy via the recursive CTE `findDescendantIdsByName`. See ADR-033. **PersonHint** — a lightweight `{id, displayName}` pair used in `NlQueryInterpretation` to describe a resolved or ambiguous person without exposing the full `Person` entity to the frontend. +**TagHint** — a lightweight `{id, name, color?}` triple used in `NlQueryInterpretation.resolvedTags` to describe a tag matched by keyword→tag resolution. `color` is the tag's effective color (one-level inheritance from parent when the tag has no own color), or null if neither tag nor parent has a color. + +**theme chip** `[frontend]` — a removable chip rendered in `InterpretationChipRow` for each entry in `NlQueryInterpretation.resolvedTags` when `tagsApplied` is `true`. Displays "Thema: {tag.name}" (prefix varies by locale). Clicking × removes the tag from the OR-union filter and navigates to `/documents?tag=…&tagOp=OR` with remaining tag and person parameters preserved. + --- ## Infrastructure Terms diff --git a/docs/adr/028-nl-search-ollama.md b/docs/adr/028-nl-search-ollama.md index 41459992..ab166acc 100644 --- a/docs/adr/028-nl-search-ollama.md +++ b/docs/adr/028-nl-search-ollama.md @@ -26,7 +26,7 @@ Family members write their search intent in plain German ("Was hat Walter im Kri **DB-blind name resolution:** The Ollama prompt stays small (the raw query only); person database records are never sent to the model. Name resolution happens as a cheap SQL query after the model returns. This keeps the prompt short, avoids data leakage, and means adding 1,000 new persons requires no prompt change. -**Graceful degradation:** `RestClientOllamaClient.isHealthy()` is called inline before each inference request (calls `GET /api/tags` on a 2-second connect-timeout client). If Ollama is absent or times out, `NlQueryParserService` throws `DomainException` with `SMART_SEARCH_UNAVAILABLE` (HTTP 503). The regular structured search (`GET /api/documents/search`) is unaffected — it never calls Ollama. +**Graceful degradation:** In-path Ollama failures surface via `OllamaClient.parse()` — any `IOException`, read timeout, or non-2xx response is caught by `RestClientOllamaClient` and re-thrown as `DomainException(SMART_SEARCH_UNAVAILABLE, HTTP 503)`. `isHealthy()` has no callers inside `search/`; it is reserved for the ops/health-endpoint polling path only (e.g. a future `/api/health/ollama` endpoint). The regular structured search (`GET /api/documents/search`) is unaffected — it never calls Ollama. **Expected inference latency:** 2–15 seconds on the current CPU-only hardware. The frontend issue must show a persistent "Suche läuft…" indicator for the full duration (see `aria-live="polite"` requirement in issue #738 frontend notes). The backend timeout is 30 seconds (`app.ollama.timeout-seconds=30`) — chosen as a safe upper bound for Q4_K_M on the i7-6700 with a realistic 500-character query under modest concurrent load. @@ -44,6 +44,8 @@ Family members write their search intent in plain German ("Was hat Walter im Kri **`search/` → `person/` + `document/` dependency direction:** `NlQueryParserService` calls `PersonService.findByDisplayNameContaining()` and `DocumentService.searchDocuments()` — both are legitimate cross-domain service calls, not repository leaks. The `search/` package has no JPA entities of its own and never accesses `PersonRepository` or `DocumentRepository` directly. +**Keyword→tag resolution** (issue #743): After Ollama extracts the `keywords` list, `NlQueryParserService` calls `TagService.findByNameContaining()` for each keyword. Keywords that match one or more tags are removed from the FTS text list and added as OR-union tag filters; keywords with no tag match remain as FTS text. Resolved tags are returned to the frontend as `TagHint` objects in `NlQueryInterpretation.resolvedTags` and rendered as removable "Thema: X" chips. The `tagsApplied` flag signals whether the OR-union filter was actually passed to `DocumentService.searchDocuments()` — it is `false` when the `personRole:any` single-person path is taken, because that path has no tag filter slot. See ADR-033 for the tag name resolution and case-collision rules that `TagService.findByNameContaining()` relies on. + ## Decision **Introduce a new `search/` domain package** with a local Ollama integration via `RestClientOllamaClient`. The Ollama service runs as a separate Docker container, reachable only on the internal Docker network (`expose: ["11434"]`, not `ports:`). The backend calls Ollama's `/api/generate` endpoint with grammar-constrained JSON output. Name resolution and document search are performed by existing services after the model returns. diff --git a/docs/architecture/c4/l3-backend-3h-search.puml b/docs/architecture/c4/l3-backend-3h-search.puml index a0d643be..7ffa28be 100644 --- a/docs/architecture/c4/l3-backend-3h-search.puml +++ b/docs/architecture/c4/l3-backend-3h-search.puml @@ -10,7 +10,7 @@ Container(ollama, "Ollama", "ollama/ollama — port 11434 (internal only)") System_Boundary(backend, "API Backend (Spring Boot)") { Component(nlCtrl, "NlSearchController", "Spring MVC — POST /api/search/nl", "REST entry point for natural language search. Enforces READ_ALL permission. Uses @AuthenticationPrincipal UserDetails to obtain the caller's email for rate limiting. Delegates to NlQueryParserService and returns NlSearchResponse.") Component(rateLimiter, "NlSearchRateLimiter", "Spring Service", "Bucket4j + Caffeine LoadingCache keyed on user email. Allows 5 NL search requests per minute per user. Throws DomainException(SMART_SEARCH_RATE_LIMITED / HTTP 429) when the bucket is exhausted. Node-local — same caveat as LoginRateLimiter.") - Component(parserSvc, "NlQueryParserService", "Spring Service", "Orchestrates the full NL search pipeline: (1) validates query length, (2) calls OllamaClient.parse() to extract structured intent, (3) resolves each person name via PersonService.findByDisplayNameContaining(), (4) applies multi-name / personRole heuristics, (5) delegates to DocumentService.searchDocuments() or searchDocumentsByPersonId(). Returns NlSearchResponse. Never logs raw query content (PII).") + Component(parserSvc, "NlQueryParserService", "Spring Service", "Orchestrates the full NL search pipeline: (1) validates query length, (2) calls OllamaClient.parse() to extract structured intent, (3) resolves keywords to tags via TagService.findByNameContaining(), (4) resolves each person name via PersonService.findByDisplayNameContaining(), (5) applies multi-name / personRole heuristics, (6) delegates to DocumentService.searchDocuments() or searchDocumentsByPersonId(). Returns NlSearchResponse. Never logs raw query content (PII).") Component(ollamaClient, "RestClientOllamaClient", "Spring Service — implements OllamaClient + OllamaHealthClient", "HTTP client for the Ollama API. Uses two separate RestClient instances: inference client (30 s read timeout) and health-check client (2 s connect timeout). Calls POST /api/generate with grammar-constrained JSON schema (personNames, personRole, dateFrom, dateTo, keywords). isHealthy() polls GET /api/tags. Null-coalesces absent personNames/keywords to List.of(). Defaults unknown personRole to 'any' with a warning log. Maps timeout/5xx/parse errors to DomainException(SMART_SEARCH_UNAVAILABLE / HTTP 503).") Component(ollamaProps, "OllamaProperties", "@ConfigurationProperties(\"app.ollama\")", "Config bean: baseUrl, model (qwen2.5:7b-instruct-q4_K_M), timeoutSeconds (default: 30), healthCheckTimeoutSeconds (default: 2).") Component(rateLimitProps, "NlSearchRateLimitProperties", "@ConfigurationProperties(\"app.nl-search.rate-limit\")", "Config bean: maxRequestsPerMinute (default: 5).") @@ -18,6 +18,7 @@ System_Boundary(backend, "API Backend (Spring Boot)") { Component(personSvc, "PersonService", "Spring Service", "See diagram 3e. findByDisplayNameContaining(fragment) delegates to PersonRepository.searchByName() — covers first+last name, alias, and name aliases via LEFT JOIN.") Component(documentSvc, "DocumentService", "Spring Service", "See diagram 3b. searchDocuments() for keyword/sender/receiver/date queries. searchDocumentsByPersonId() for OR-semantics single-person queries (person as sender OR receiver, no keyword filter).") +Component(tagSvc, "TagService", "Spring Service", "See diagram 3b. findByNameContaining(fragment) delegates to TagRepository.findByNameContainingIgnoreCase(). resolveEffectiveColors() applies one-level color inheritance in-place on a collection of Tag entities.") Rel(frontend, nlCtrl, "POST /api/search/nl with JSON query", "HTTP / JSON") Rel(nlCtrl, rateLimiter, "checkAndConsume(userEmail)") @@ -25,9 +26,12 @@ Rel(nlCtrl, parserSvc, "parse(query)") Rel(parserSvc, ollamaClient, "parse(rawQuery) — extracts intent", "HTTP / JSON") Rel(ollamaClient, ollama, "POST /api/generate (grammar-constrained JSON schema)", "HTTP / REST") Rel(ollamaClient, ollama, "GET /api/tags (health check)", "HTTP / REST") +Rel(parserSvc, tagSvc, "findByNameContaining(keyword) — keyword→tag resolution") +Rel(parserSvc, tagSvc, "resolveEffectiveColors(tags)") Rel(parserSvc, personSvc, "findByDisplayNameContaining(name) for each extracted name") Rel(parserSvc, documentSvc, "searchDocuments() or searchDocumentsByPersonId()") Rel(documentSvc, db, "JPA queries", "JDBC") Rel(personSvc, db, "JPA queries", "JDBC") +Rel(tagSvc, db, "JPA queries", "JDBC") @enduml From 9c616f9fb8221d3f6fa9c025933c022405afc27c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 7 Jun 2026 00:49:52 +0200 Subject: [PATCH 21/29] feat(person): add name-match tokenizer for direct matching (#763) Lowercase, split on whitespace/hyphen/apostrophe, drop empties. Applied symmetrically to query and candidate name components so "Anna-Maria" and "Anna Maria" tokenize alike. Foundation for resolveByName direct matching. Co-Authored-By: Claude Opus 4.8 --- .../familienarchiv/person/PersonService.java | 22 +++++++++++++ .../person/PersonServiceTest.java | 32 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java index 23d38baa..c9710c7e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java @@ -1,8 +1,12 @@ package org.raddatz.familienarchiv.person; +import java.util.ArrayList; import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.springframework.lang.Nullable; @@ -24,9 +28,11 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Service @RequiredArgsConstructor +@Slf4j public class PersonService { private final PersonRepository personRepository; @@ -103,6 +109,22 @@ public class PersonService { return personRepository.searchByName(fragment); } + // Name-match tokenizer (issue #763): lowercase, split on whitespace/hyphen/apostrophe, + // drop empties. Applied symmetrically to the query and to every candidate name component so + // that "Anna-Maria" and "Anna Maria" tokenize alike. Order-preserving for deterministic tests. + static Set tokenize(String raw) { + if (raw == null || raw.isBlank()) { + return Set.of(); + } + LinkedHashSet tokens = new LinkedHashSet<>(); + for (String part : raw.toLowerCase(Locale.ROOT).split("[\\s\\-']+")) { + if (!part.isEmpty()) { + tokens.add(part); + } + } + return tokens; + } + public List findAllFamilyMembers() { return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc(); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java index 865ae9ad..248504c5 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java @@ -909,4 +909,36 @@ class PersonServiceTest { assertThat(result).containsExactly(walter); verify(personRepository).searchByName("Walter"); } + + // ─── tokenize (name-match contract) ─────────────────────────────────────── + + @Test + void tokenize_hyphenatedName_splitsOnHyphen() { + assertThat(PersonService.tokenize("Anna-Maria")).containsExactly("anna", "maria"); + } + + @Test + void tokenize_apostropheName_splitsOnApostrophe() { + assertThat(PersonService.tokenize("D'Angelo")).containsExactly("d", "angelo"); + } + + @Test + void tokenize_umlautName_lowercasesToSingleToken() { + assertThat(PersonService.tokenize("Müller")).containsExactly("müller"); + } + + @Test + void tokenize_doubleSpace_dropsEmptyTokens() { + assertThat(PersonService.tokenize("Clara Cram")).containsExactly("clara", "cram"); + } + + @Test + void tokenize_allWhitespace_returnsEmpty() { + assertThat(PersonService.tokenize(" ")).isEmpty(); + } + + @Test + void tokenize_null_returnsEmpty() { + assertThat(PersonService.tokenize(null)).isEmpty(); + } } From 9a26bf75b049ace7a469b374e9e174d6dcea0879 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 7 Jun 2026 00:52:20 +0200 Subject: [PATCH 22/29] feat(person): match alias first names in searchByName (#763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The direct-match classifier accepts alias firstName tokens, so the fetch must surface candidates matchable only via an alias first name. Add a.firstName to the searchByName LIKE clause (reuses the bound :query — injection-proof). The person_name_aliases.first_name column already exists; no migration. Co-Authored-By: Claude Opus 4.8 --- .../person/PersonRepository.java | 3 ++- .../person/PersonRepositoryTest.java | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java index fe619e0b..0afc9e66 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java @@ -19,7 +19,8 @@ public interface PersonRepository extends JpaRepository { "LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " + "LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " + "LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " + - "LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " + + "LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) OR " + + "LOWER(a.firstName) LIKE LOWER(CONCAT('%', :query, '%')) " + "ORDER BY p.lastName ASC, p.firstName ASC") List searchByName(@Param("query") String query); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java index e53366b9..131bfb1d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java @@ -428,6 +428,31 @@ class PersonRepositoryTest { assertThat(results).hasSize(1); } + @Test + void searchByName_findsByAliasFirstName() { + Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build()); + aliasRepository.save(PersonNameAlias.builder() + .person(clara).firstName("Wilhelmina").lastName("de Gruyter") + .type(PersonNameAliasType.BIRTH).sortOrder(0).build()); + + List results = personRepository.searchByName("Wilhelmina"); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getLastName()).isEqualTo("Cram"); + } + + @Test + void searchByName_ordersByLastNameThenFirstName() { + personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build()); + personRepository.save(Person.builder().firstName("Anna").lastName("Cram").build()); + personRepository.save(Person.builder().firstName("Bernd").lastName("Cram").build()); + + List results = personRepository.searchByName("Cram"); + + assertThat(results).extracting(Person::getFirstName) + .containsExactly("Anna", "Bernd", "Clara"); + } + // ─── searchWithDocumentCount with aliases ──────────────────────────────── @Test From ca52145556a112f1186793b4fddd7bb82416c1cf Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 7 Jun 2026 00:55:13 +0200 Subject: [PATCH 23/29] feat(person): add resolveByName for direct/partial name matching (#763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Token-set containment over all of a person's name components (firstName, lastName, alias, each PersonNameAlias first+last, title) decides direct vs partial. Orchestrates tokenize → cap(8) → fetch pool → classify → cap(10) after classification, with an empty-token guard and a PII-free debug log of the outcome bucket. MAX_TOKENS is a DoS control; the after-classify cap keeps a direct match that sorts past position 10 among partials. Read-only transaction keeps lazy nameAliases reachable during classification (ADR-022). Co-Authored-By: Claude Opus 4.8 --- .../familienarchiv/person/NameMatches.java | 13 +++ .../familienarchiv/person/PersonService.java | 79 +++++++++++++ .../person/PersonServiceTest.java | 104 ++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/person/NameMatches.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/NameMatches.java b/backend/src/main/java/org/raddatz/familienarchiv/person/NameMatches.java new file mode 100644 index 00000000..ddc7a1c6 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/NameMatches.java @@ -0,0 +1,13 @@ +package org.raddatz.familienarchiv.person; + +import java.util.List; + +/** + * Result of {@link PersonService#resolveByName(String)}: candidate persons split by name-match + * strength. {@code direct} = every query token is a whole-token match across the person's name + * components (alias/maiden-name aware); {@code partial} = matched the substring fetch but is not + * direct. The vocabulary is deliberately name-match strength ({@code direct}/{@code partial}), not + * the search layer's resolved/ambiguous buckets — the caller maps these into its own outcome. + */ +public record NameMatches(List direct, List partial) { +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java index c9710c7e..d195d0bb 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java @@ -2,12 +2,14 @@ package org.raddatz.familienarchiv.person; import java.util.ArrayList; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; import org.springframework.lang.Nullable; @@ -35,6 +37,13 @@ import lombok.extern.slf4j.Slf4j; @Slf4j public class PersonService { + // Co-located with the fetch loop that owns them (issue #763). MAX_TOKENS caps the number of + // unindexed leading-wildcard LIKE scans per name — a DoS control, not just perf. MAX_CANDIDATES + // bounds each result bucket and is applied AFTER classification so a direct match that sorts + // past position 10 among partials is never discarded. + private static final int MAX_TOKENS = 8; + private static final int MAX_CANDIDATES = 10; + private final PersonRepository personRepository; private final PersonNameAliasRepository aliasRepository; @@ -125,6 +134,76 @@ public class PersonService { return tokens; } + /** + * Resolves an extracted person name into {@link NameMatches} by name-match strength. + * Orchestrates tokenize → cap → fetch pool → classify → cap-after-classify. Read-only + * transaction keeps the Hibernate session open so each candidate's lazy {@code nameAliases} + * are reachable during classification (see ADR-022). + */ + @Transactional(readOnly = true) + public NameMatches resolveByName(String name) { + Set queryTokens = capTokens(tokenize(name)); + if (queryTokens.isEmpty()) { + log.debug("resolveByName outcome=no-match tokens=0"); + return new NameMatches(List.of(), List.of()); + } + return classify(fetchPool(queryTokens), queryTokens); + } + + private Set capTokens(Set tokens) { + return tokens.stream().limit(MAX_TOKENS).collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private List fetchPool(Set queryTokens) { + LinkedHashMap pool = new LinkedHashMap<>(); + for (String token : queryTokens) { + for (Person candidate : findByDisplayNameContaining(token)) { + pool.putIfAbsent(candidate.getId(), candidate); + } + } + return new ArrayList<>(pool.values()); + } + + private NameMatches classify(List pool, Set queryTokens) { + List direct = new ArrayList<>(); + List partial = new ArrayList<>(); + for (Person candidate : pool) { + if (personTokens(candidate).containsAll(queryTokens)) { + direct.add(candidate); + } else { + partial.add(candidate); + } + } + List cappedDirect = cap(direct); + List cappedPartial = cap(partial); + log.debug("resolveByName outcome={} tokens={}", outcome(cappedDirect, cappedPartial), queryTokens.size()); + return new NameMatches(cappedDirect, cappedPartial); + } + + private static Set personTokens(Person person) { + Set tokens = new LinkedHashSet<>(); + tokens.addAll(tokenize(person.getFirstName())); + tokens.addAll(tokenize(person.getLastName())); + tokens.addAll(tokenize(person.getAlias())); + tokens.addAll(tokenize(person.getTitle())); + for (PersonNameAlias alias : person.getNameAliases()) { + tokens.addAll(tokenize(alias.getFirstName())); + tokens.addAll(tokenize(alias.getLastName())); + } + return tokens; + } + + private static List cap(List people) { + return people.size() > MAX_CANDIDATES ? people.subList(0, MAX_CANDIDATES) : people; + } + + private static String outcome(List direct, List partial) { + if (direct.size() == 1) return "direct=1"; + if (direct.size() >= 2) return "direct>=2"; + if (!partial.isEmpty()) return "partial-only"; + return "no-match"; + } + public List findAllFamilyMembers() { return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc(); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java index 248504c5..43afccd7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java @@ -941,4 +941,108 @@ class PersonServiceTest { void tokenize_null_returnsEmpty() { assertThat(PersonService.tokenize(null)).isEmpty(); } + + // ─── resolveByName (direct / partial classification) ────────────────────── + + @Test + void resolveByName_singleDirectMatch_classifiesAsDirect() { + Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build(); + when(personRepository.searchByName("clara")).thenReturn(List.of(clara)); + when(personRepository.searchByName("cram")).thenReturn(List.of(clara)); + + NameMatches result = personService.resolveByName("Clara Cram"); + + assertThat(result.direct()).containsExactly(clara); + } + + @Test + void resolveByName_maidenAliasToken_classifiesAsDirect() { + Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Müller") + .nameAliases(List.of(PersonNameAlias.builder().lastName("Cram") + .type(PersonNameAliasType.MAIDEN_NAME).build())) + .build(); + when(personRepository.searchByName("clara")).thenReturn(List.of(clara)); + when(personRepository.searchByName("cram")).thenReturn(List.of(clara)); + + NameMatches result = personService.resolveByName("Clara Cram"); + + assertThat(result.direct()).containsExactly(clara); + } + + @Test + void resolveByName_aliasFirstNameToken_isFetchedAndClassified() { + Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram") + .nameAliases(List.of(PersonNameAlias.builder().firstName("Wilhelmina").lastName("de Gruyter") + .type(PersonNameAliasType.BIRTH).build())) + .build(); + when(personRepository.searchByName("wilhelmina")).thenReturn(List.of(clara)); + + NameMatches result = personService.resolveByName("Wilhelmina"); + + assertThat(result.direct()).containsExactly(clara); + } + + @Test + void resolveByName_middleName_stillDirect() { + Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara Maria").lastName("Cram").build(); + when(personRepository.searchByName("clara")).thenReturn(List.of(clara)); + when(personRepository.searchByName("cram")).thenReturn(List.of(clara)); + + NameMatches result = personService.resolveByName("Clara Cram"); + + assertThat(result.direct()).containsExactly(clara); + } + + @Test + void resolveByName_reorderedTokens_stillDirect() { + Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build(); + when(personRepository.searchByName("cram")).thenReturn(List.of(clara)); + when(personRepository.searchByName("clara")).thenReturn(List.of(clara)); + + NameMatches result = personService.resolveByName("Cram Clara"); + + assertThat(result.direct()).containsExactly(clara); + } + + @Test + void resolveByName_cramVsCramer_classifiesAsPartial() { + Person cramer = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build(); + when(personRepository.searchByName("clara")).thenReturn(List.of(cramer)); + when(personRepository.searchByName("cram")).thenReturn(List.of(cramer)); + + NameMatches result = personService.resolveByName("Clara Cram"); + + assertThat(result.partial()).containsExactly(cramer); + } + + @Test + void resolveByName_emptyAfterTokenizing_returnsNoCandidates() { + NameMatches result = personService.resolveByName(" - "); + + assertThat(result.direct()).isEmpty(); + verify(personRepository, never()).searchByName(any()); + } + + @Test + void resolveByName_directSortsBeyondCap_stillReturnedAsDirect() { + List pool = new java.util.ArrayList<>(); + for (int i = 0; i < 10; i++) { + pool.add(Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build()); + } + Person direct = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build(); + pool.add(direct); + when(personRepository.searchByName("clara")).thenReturn(pool); + when(personRepository.searchByName("cram")).thenReturn(pool); + + NameMatches result = personService.resolveByName("Clara Cram"); + + assertThat(result.direct()).containsExactly(direct); + } + + @Test + void resolveByName_over8Tokens_issuesAtMost8Fetches() { + personService.resolveByName("a b c d e f g h i j"); + + verify(personRepository, org.mockito.Mockito.atMost(8)).searchByName(any()); + } } From f1bb9d3a6974fe0819dd5df19c16621acd0d19ab Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 7 Jun 2026 00:59:01 +0200 Subject: [PATCH 24/29] feat(search): map direct/partial NameMatches into resolve buckets (#763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveNames now delegates to PersonService.resolveByName and maps by match strength: 1 direct → resolved (auto-select), ≥2 direct → ambiguous, 0 direct with partials → ambiguous suggestions, 0 candidates → folded into full-text. A single direct match no longer forces the picker when looser substring hits coexist. The MAX_CANDIDATES cap moved into PersonService (after classification); the MAX_NAME_LENGTH guard, resolved-cap overflow, and sender/receiver mapping are preserved. Co-Authored-By: Claude Opus 4.8 --- .../search/NlQueryParserService.java | 24 ++-- .../search/NlQueryParserServiceTest.java | 108 ++++++++++++++---- 2 files changed, 96 insertions(+), 36 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java index 41d3dbd2..81218e2d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java @@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.document.DocumentSort; import org.raddatz.familienarchiv.document.SearchFilters; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.person.NameMatches; import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.PersonService; import org.raddatz.familienarchiv.tag.Tag; @@ -30,7 +31,6 @@ public class NlQueryParserService { private static final int MIN_QUERY = 3; private static final int MAX_QUERY = 500; private static final int MAX_NAME_LENGTH = 200; - private static final int MAX_CANDIDATES = 10; private static final int MIN_TAG_TERM = 3; private static final int MAX_RESOLVED_TAGS = 10; @@ -113,24 +113,24 @@ public class NlQueryParserService { log.debug("Skipping name fragment (too long or null): length={}", name == null ? 0 : name.length()); continue; } - List candidates = personService.findByDisplayNameContaining(name); - List capped = candidates.size() > MAX_CANDIDATES - ? candidates.subList(0, MAX_CANDIDATES) - : candidates; + NameMatches matches = personService.resolveByName(name); + List direct = matches.direct(); + List partial = matches.partial(); - if (capped.isEmpty()) { - noMatchFragments.add(name); - } else if (capped.size() == 1) { - Person p = capped.get(0); - PersonHint hint = new PersonHint(p.getId(), p.getDisplayName()); + if (direct.size() == 1) { + Person p = direct.get(0); resolvedIndex++; if (resolvedIndex <= 2) { - resolved.add(hint); + resolved.add(new PersonHint(p.getId(), p.getDisplayName())); } else { extraFragments.add(name); } + } else if (direct.size() >= 2) { + direct.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName()))); + } else if (!partial.isEmpty()) { + partial.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName()))); } else { - capped.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName()))); + noMatchFragments.add(name); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java index d1e9c970..61c00b6a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java @@ -11,6 +11,7 @@ import org.raddatz.familienarchiv.document.DocumentSort; import org.raddatz.familienarchiv.document.SearchFilters; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.person.NameMatches; import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.PersonService; import org.raddatz.familienarchiv.tag.Tag; @@ -64,6 +65,18 @@ class NlQueryParserServiceTest { return Person.builder().id(id).firstName(firstName).lastName(lastName).build(); } + private NameMatches makeNameMatches() { + return new NameMatches(List.of(), List.of()); + } + + private NameMatches makeNameMatches(List direct) { + return new NameMatches(direct, List.of()); + } + + private NameMatches makeNameMatches(List direct, List partial) { + return new NameMatches(direct, partial); + } + private static final UUID P1 = UUID.fromString("00000000-0000-0000-0000-000000000001"); private static final UUID P2 = UUID.fromString("00000000-0000-0000-0000-000000000002"); private static final UUID P3 = UUID.fromString("00000000-0000-0000-0000-000000000003"); @@ -75,7 +88,7 @@ class NlQueryParserServiceTest { Person walter = person(P1, "Walter", "Raddatz"); when(ollamaClient.parse(anyString())) .thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of())); - when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter)); + when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); NlSearchResponse resp = service.search("Was hat Walter geschrieben?", PAGE); @@ -96,7 +109,7 @@ class NlQueryParserServiceTest { Person b = person(UUID.randomUUID(), "Walter", "Schmidt"); when(ollamaClient.parse(anyString())) .thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of())); - when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(a, b)); + when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(a, b))); NlSearchResponse resp = service.search("Briefe von Walter", PAGE); @@ -114,7 +127,7 @@ class NlQueryParserServiceTest { Person b = person(UUID.randomUUID(), "Emma", "Raddatz"); when(ollamaClient.parse(anyString())) .thenReturn(extraction(List.of("Emma"), "any", null, null, List.of())); - when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(a, b)); + when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(a, b))); NlSearchResponse resp = service.search("Briefe an Emma", PAGE); @@ -129,7 +142,7 @@ class NlQueryParserServiceTest { void search_noMatchName_isFoldedIntoText() { when(ollamaClient.parse(anyString())) .thenReturn(extraction(List.of("Karl"), "any", null, null, List.of())); - when(personService.findByDisplayNameContaining("Karl")).thenReturn(List.of()); + when(personService.resolveByName("Karl")).thenReturn(makeNameMatches()); service.search("Briefe von Karl", PAGE); @@ -147,7 +160,7 @@ class NlQueryParserServiceTest { Person walter = person(P1, "Walter", "Raddatz"); when(ollamaClient.parse(anyString())) .thenReturn(extraction(List.of("Walter"), "any", null, null, List.of())); - when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter)); + when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); NlSearchResponse resp = service.search("Briefe von Walter", PAGE); @@ -164,8 +177,8 @@ class NlQueryParserServiceTest { Person emma = person(P2, "Emma", "Raddatz"); when(ollamaClient.parse(anyString())) .thenReturn(extraction(List.of("Walter", "Emma"), "any", null, null, List.of())); - when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter)); - when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma)); + when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); + when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma))); NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE); @@ -186,8 +199,8 @@ class NlQueryParserServiceTest { Person emma2 = person(P3, "Emma", "Schmidt"); when(ollamaClient.parse(anyString())) .thenReturn(extraction(List.of("Walter", "Emma"), "sender", null, null, List.of())); - when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter)); - when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma1, emma2)); + when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); + when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma1, emma2))); NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE); @@ -202,8 +215,8 @@ class NlQueryParserServiceTest { Person emma = person(P2, "Emma", "Raddatz"); when(ollamaClient.parse(anyString())) .thenReturn(extraction(List.of("Karl", "Emma"), "sender", null, null, List.of())); - when(personService.findByDisplayNameContaining("Karl")).thenReturn(List.of()); - when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma)); + when(personService.resolveByName("Karl")).thenReturn(makeNameMatches()); + when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma))); service.search("Briefe von Karl an Emma", PAGE); @@ -222,9 +235,9 @@ class NlQueryParserServiceTest { Person heinrich = person(P3, "Heinrich", "Braun"); when(ollamaClient.parse(anyString())) .thenReturn(extraction(List.of("Walter", "Emma", "Heinrich"), "any", null, null, List.of())); - when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter)); - when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma)); - when(personService.findByDisplayNameContaining("Heinrich")).thenReturn(List.of(heinrich)); + when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); + when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma))); + when(personService.resolveByName("Heinrich")).thenReturn(makeNameMatches(List.of(heinrich))); service.search("Briefe von Walter an Emma über Heinrich", PAGE); @@ -343,7 +356,7 @@ class NlQueryParserServiceTest { // but NlQueryParserService must also be safe if something unexpected arrives. when(ollamaClient.parse(anyString())) .thenReturn(new OllamaExtraction(List.of("Walter"), "unknown_role", null, null, List.of(), "query")); - when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter)); + when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); NlSearchResponse resp = service.search("Briefe von Walter", PAGE); @@ -374,20 +387,21 @@ class NlQueryParserServiceTest { service.search("Briefe von sehr langem Namen", PAGE); - verify(personService, never()).findByDisplayNameContaining(anyString()); + verify(personService, never()).resolveByName(anyString()); } - // --- 20. Max 10 candidates cap: 11 persons returned → only first 10 in ambiguousPersons --- + // --- 20. Cap lives in resolveByName (after classification): a pre-capped 10-direct result + // maps straight to ambiguousPersons; the search layer adds no second cap. --- @Test - void search_elevenCandidates_capsAtTen() { - List eleven = new ArrayList<>(); - for (int i = 0; i < 11; i++) { - eleven.add(person(UUID.randomUUID(), "Walter", "Person" + i)); + void search_tenDirectMatches_allShownAsAmbiguous() { + List ten = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + ten.add(person(UUID.randomUUID(), "Walter", "Person" + i)); } when(ollamaClient.parse(anyString())) .thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of())); - when(personService.findByDisplayNameContaining("Walter")).thenReturn(eleven); + when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(ten)); NlSearchResponse resp = service.search("Briefe von Walter", PAGE); @@ -421,7 +435,7 @@ class NlQueryParserServiceTest { Person emma = person(P2, "Emma", "Raddatz"); when(ollamaClient.parse(anyString())) .thenReturn(extraction(List.of("Emma"), "receiver", null, null, List.of())); - when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma)); + when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma))); service.search("Briefe an Emma", PAGE); @@ -443,6 +457,52 @@ class NlQueryParserServiceTest { assertThat(resp.interpretation().keywordsApplied()).isTrue(); } + // --- 23a. Partial-only, one candidate → ambiguous (1-item picker), search skipped --- + + @Test + void search_partialOnly_oneCandidate_populatesAmbiguous() { + Person cramer = person(P1, "Clara", "Cramer"); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of())); + when(personService.resolveByName("Clara Cram")).thenReturn(makeNameMatches(List.of(), List.of(cramer))); + + NlSearchResponse resp = service.search("Briefe von Clara Cram", PAGE); + + assertThat(resp.interpretation().ambiguousPersons()).hasSize(1); + verify(documentService, never()).searchDocuments(any(), any(), any(), any()); + } + + // --- 23b. Partial-only, two candidates → ambiguous (multi-item picker) --- + + @Test + void search_partialOnly_twoCandidates_populatesAmbiguous() { + Person cramer = person(P1, "Clara", "Cramer"); + Person crammond = person(P2, "Clara", "Crammond"); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of())); + when(personService.resolveByName("Clara Cram")) + .thenReturn(makeNameMatches(List.of(), List.of(cramer, crammond))); + + NlSearchResponse resp = service.search("Briefe von Clara Cram", PAGE); + + assertThat(resp.interpretation().ambiguousPersons()).hasSize(2); + } + + // --- 23c. Exactly one direct match → search executes, no picker --- + + @Test + void search_oneDirect_executesSearch() { + Person clara = person(P1, "Clara", "Cram"); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of())); + when(personService.resolveByName("Clara Cram")).thenReturn(makeNameMatches(List.of(clara))); + + NlSearchResponse resp = service.search("Briefe von Clara Cram", PAGE); + + verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE)); + assertThat(resp.interpretation().ambiguousPersons()).isEmpty(); + } + // --- Tag resolution helpers --- private Tag tag(UUID id, String name) { @@ -546,7 +606,7 @@ class NlQueryParserServiceTest { Tag hochzeit = tag(T1, "Hochzeit"); when(ollamaClient.parse(anyString())) .thenReturn(extraction(List.of("Walter"), "any", null, null, List.of("Hochzeit"))); - when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter)); + when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter))); when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit)); NlSearchResponse resp = service.search("Briefe von Walter über Hochzeit", PAGE); From 0ef4f4f07cb99540868574b0f1a0081a859b9dd3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 7 Jun 2026 01:14:25 +0200 Subject: [PATCH 25/29] feat(search): case-appropriate disambiguation picker copy (#763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 1-item picker now reads "Meintest du …?" (a single direct match auto-selects and never reaches the picker), while ≥2 keeps the "Person auswählen" framing. The prompt lives in a visible, non-truncated panel heading (the trigger span clips at 320px), and the "(auswählen…)" cue is dropped for the 1-item case. DisambiguationPicker takes heading + showCue props; the page derives both from ambiguousPersons.length. New search_disambiguation_did_you_mean key in de/en/es. Co-Authored-By: Claude Opus 4.8 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + frontend/src/routes/documents/+page.svelte | 13 ++++- .../routes/search/DisambiguationPicker.svelte | 51 ++++++++++++------- .../DisambiguationPicker.svelte.spec.ts | 38 ++++++++++++-- 6 files changed, 80 insertions(+), 25 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 5ea1b00a..e53f9583 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -47,6 +47,7 @@ "search_disambiguation_trigger_label": "Mehrere Personen gefunden — zum Auswählen klicken", "search_disambiguation_cue": "(auswählen…)", "search_disambiguation_heading": "Person auswählen", + "search_disambiguation_did_you_mean": "Meintest du {name}?", "search_disambiguation_select_label": "{name} auswählen", "error_validation_error": "Die Eingabe ist ungültig.", "error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index be3af0ce..84be1557 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -47,6 +47,7 @@ "search_disambiguation_trigger_label": "Several people found — click to choose", "search_disambiguation_cue": "(choose…)", "search_disambiguation_heading": "Choose a person", + "search_disambiguation_did_you_mean": "Did you mean {name}?", "search_disambiguation_select_label": "Select {name}", "error_validation_error": "The input is invalid.", "error_internal_error": "An unexpected error occurred.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index c5fb2fc8..7a40b82c 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -47,6 +47,7 @@ "search_disambiguation_trigger_label": "Se encontraron varias personas — haga clic para elegir", "search_disambiguation_cue": "(elegir…)", "search_disambiguation_heading": "Elegir una persona", + "search_disambiguation_did_you_mean": "¿Quería decir {name}?", "search_disambiguation_select_label": "Seleccionar {name}", "error_validation_error": "La entrada no es válida.", "error_internal_error": "Se ha producido un error inesperado.", diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 7e836964..7d494b24 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -57,7 +57,16 @@ let nlResult = $state(null); const showNlView = $derived(smartMode && nlSubmitted); const nlHasResults = $derived((nlResult?.items.length ?? 0) > 0); -const nlIsAmbiguous = $derived((nlInterpretation?.ambiguousPersons.length ?? 0) > 0); +const ambiguousPersons = $derived(nlInterpretation?.ambiguousPersons ?? []); +const nlIsAmbiguous = $derived(ambiguousPersons.length > 0); +// A 1-item picker is always a "did you mean …?" suggestion (a single direct match auto-selects +// and never reaches the picker); ≥2 keeps the "choose a person" framing and the action cue. +const disambiguationHeading = $derived( + ambiguousPersons.length === 1 + ? m.search_disambiguation_did_you_mean({ name: ambiguousPersons[0].displayName }) + : m.search_disambiguation_heading() +); +const showDisambiguationCue = $derived(ambiguousPersons.length >= 2); function hasAdvancedFilters() { return ( @@ -442,6 +451,8 @@ $effect(() => { {#if nlIsAmbiguous} {:else} diff --git a/frontend/src/routes/search/DisambiguationPicker.svelte b/frontend/src/routes/search/DisambiguationPicker.svelte index b99fea10..e365cdeb 100644 --- a/frontend/src/routes/search/DisambiguationPicker.svelte +++ b/frontend/src/routes/search/DisambiguationPicker.svelte @@ -6,14 +6,24 @@ import type { components } from '$lib/generated/api'; type PersonHint = components['schemas']['PersonHint']; -let { persons, onSelect }: { persons: PersonHint[]; onSelect: (person: PersonHint) => void } = - $props(); +let { + persons, + heading, + showCue, + onSelect +}: { + persons: PersonHint[]; + heading: string; + showCue: boolean; + onSelect: (person: PersonHint) => void; +} = $props(); let open = $state(false); let triggerEl = $state(); let listEl = $state(); const panelId = 'disambiguation-panel'; +const headingId = 'disambiguation-heading'; const names = $derived(persons.map((person) => person.displayName).join(', ')); async function openPicker() { @@ -59,28 +69,31 @@ function onKeydown(event: KeyboardEvent) { class="inline-flex min-h-[44px] items-center gap-1.5 rounded-full border border-line bg-muted px-3 text-sm text-ink-2 outline-none focus-visible:ring-2 focus-visible:ring-brand-navy" > {names} - {m.search_disambiguation_cue()} + {#if showCue} + {m.search_disambiguation_cue()} + {/if} {#if open} -
    - {#each persons as person (person.id)} -
  • - -
  • - {/each} -
+

{heading}

+
    + {#each persons as person (person.id)} +
  • + +
  • + {/each} +
+ {/if} diff --git a/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts b/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts index 5b87a996..2f90f0aa 100644 --- a/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts +++ b/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts @@ -13,6 +13,8 @@ const persons: PersonHint[] = [ { id: 'w2', displayName: 'Walter Müller' } ]; +const multiProps = { persons, heading: 'Person auswählen', showCue: true }; + function pressEscape() { (document.activeElement as HTMLElement).dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) @@ -21,7 +23,7 @@ function pressEscape() { describe('DisambiguationPicker', () => { it('opens the picker and shows a select option per ambiguous person', async () => { - render(DisambiguationPicker, { persons, onSelect: vi.fn() }); + render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() }); await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click(); await expect .element(page.getByRole('button', { name: 'Walter Raddatz auswählen' })) @@ -32,7 +34,7 @@ describe('DisambiguationPicker', () => { }); it('moves focus into the picker list on open', async () => { - render(DisambiguationPicker, { persons, onSelect: vi.fn() }); + render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() }); await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click(); await expect .element(page.getByRole('button', { name: 'Walter Raddatz auswählen' })) @@ -40,7 +42,7 @@ describe('DisambiguationPicker', () => { }); it('returns focus to the trigger when closed with Escape', async () => { - render(DisambiguationPicker, { persons, onSelect: vi.fn() }); + render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() }); const trigger = page.getByRole('button', { name: /Mehrere Personen gefunden/ }); await trigger.click(); await expect @@ -52,7 +54,7 @@ describe('DisambiguationPicker', () => { it('does not call onSelect when dismissed without choosing', async () => { const onSelect = vi.fn(); - render(DisambiguationPicker, { persons, onSelect }); + render(DisambiguationPicker, { ...multiProps, onSelect }); await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click(); await expect .element(page.getByRole('button', { name: 'Walter Raddatz auswählen' })) @@ -63,9 +65,35 @@ describe('DisambiguationPicker', () => { it('calls onSelect with the chosen person', async () => { const onSelect = vi.fn(); - render(DisambiguationPicker, { persons, onSelect }); + render(DisambiguationPicker, { ...multiProps, onSelect }); await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click(); await page.getByRole('button', { name: 'Walter Müller auswählen' }).click(); expect(onSelect).toHaveBeenCalledWith(persons[1]); }); + + it('renders the supplied heading as a visible panel heading', async () => { + render(DisambiguationPicker, { + persons: [{ id: 'c1', displayName: 'Clara Cramer' }], + heading: 'Meintest du Clara Cramer?', + showCue: false, + onSelect: vi.fn() + }); + await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click(); + await expect.element(page.getByText('Meintest du Clara Cramer?')).toBeVisible(); + }); + + it('suppresses the cue when showCue is false', async () => { + render(DisambiguationPicker, { + persons: [{ id: 'c1', displayName: 'Clara Cramer' }], + heading: 'Meintest du Clara Cramer?', + showCue: false, + onSelect: vi.fn() + }); + await expect.element(page.getByText('(auswählen…)')).not.toBeInTheDocument(); + }); + + it('shows the cue when showCue is true', async () => { + render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() }); + await expect.element(page.getByText('(auswählen…)')).toBeVisible(); + }); }); From 6959651b3611dd092181212daab646a1a10b4ece Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 7 Jun 2026 01:15:49 +0200 Subject: [PATCH 26/29] docs(search): document NameMatches and resolveByName (#763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GLOSSARY entry for NameMatches (direct vs partial name-match strength and how the search layer maps it); person/README adds resolveByName to the public surface. No ADR — the matching rule is localized and justified inline. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/org/raddatz/familienarchiv/person/README.md | 1 + docs/GLOSSARY.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/README.md b/backend/src/main/java/org/raddatz/familienarchiv/person/README.md index ffbe6c72..48105b6c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/README.md +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/README.md @@ -21,6 +21,7 @@ Features: person CRUD, name alias management, person merge (deduplication), fami | `getAllById(List)` | document | Bulk fetch for sender/receiver resolution | | `findAll(String q)` | document, dashboard | List all persons | | `findByName(String firstName, String lastName)` | document | Filename-based **sender resolution** in `storeDocument`: exact-case match → single case-insensitive match → else **empty** (ambiguous names leave the sender unset; a null first name never matches). See ADR-033. | +| `resolveByName(String name)` | search | NL-search name resolution returning `NameMatches` (direct vs partial). Token/word-boundary, alias-aware matching so a single direct match auto-selects even when looser substring hits coexist ("Clara Cram" vs "Clara Cramer"). See #763. | | `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally. Resolves exact-case → lowest-id case-insensitive sibling → create — never throws on case-colliding aliases. See ADR-033. | | `findAllFamilyMembers()` | dashboard | Family member list for stats | | `findCorrespondents()` | document | Correspondent list for conversation filter | diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 7ee1b565..e30cf875 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -177,6 +177,8 @@ _See also [Chronik](#chronik-internal)._ **PersonHint** — a lightweight `{id, displayName}` pair used in `NlQueryInterpretation` to describe a resolved or ambiguous person without exposing the full `Person` entity to the frontend. +**NameMatches** — the Person-domain result of `PersonService.resolveByName(name)`: candidate persons split by name-match strength into `direct` and `partial`. A match is **direct** when every query token is a whole-token match (order-independent, alias/maiden-name aware) across all of a person's name components (`firstName`, `lastName`, `alias`, each `PersonNameAlias` first+last, `title`); a **partial** matched the substring fetch but is not direct (e.g. "Cram" → "Clara Cramer"). The vocabulary is deliberately match strength, not the search layer's resolved/ambiguous buckets — `NlQueryParserService` maps one direct → resolved (auto-select), ≥2 direct → ambiguous, partial-only → ambiguous suggestions ("Meintest du …?"), and no candidates → folded into full-text search. + **TagHint** — a lightweight `{id, name, color?}` triple used in `NlQueryInterpretation.resolvedTags` to describe a tag matched by keyword→tag resolution. `color` is the tag's effective color (one-level inheritance from parent when the tag has no own color), or null if neither tag nor parent has a color. **theme chip** `[frontend]` — a removable chip rendered in `InterpretationChipRow` for each entry in `NlQueryInterpretation.resolvedTags` when `tagsApplied` is `true`. Displays "Thema: {tag.name}" (prefix varies by locale). Clicking × removes the tag from the OR-union filter and navigates to `/documents?tag=…&tagOp=OR` with remaining tag and person parameters preserved. From 8429b1e9f8367100c15d5fac1619a4ba33945867 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 7 Jun 2026 01:26:30 +0200 Subject: [PATCH 27/29] fix(search): derive disambiguation trigger aria-label from match count (#763 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trigger hardcoded the multiple-people label for every count, so a single did-you-mean picker announced "Mehrere Personen gefunden" to screen readers while sighted users saw one name and a "Meintest du …?" heading. Derive the trigger's accessible name from persons.length: a single suggestion reuses the heading prop, two or more keep the multiple-people label. Visible truncated name span unchanged. Co-Authored-By: Claude Opus 4.8 --- .../routes/search/DisambiguationPicker.svelte | 5 ++++- .../DisambiguationPicker.svelte.spec.ts | 21 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/search/DisambiguationPicker.svelte b/frontend/src/routes/search/DisambiguationPicker.svelte index e365cdeb..c99619c9 100644 --- a/frontend/src/routes/search/DisambiguationPicker.svelte +++ b/frontend/src/routes/search/DisambiguationPicker.svelte @@ -25,6 +25,9 @@ let listEl = $state(); const panelId = 'disambiguation-panel'; const headingId = 'disambiguation-heading'; const names = $derived(persons.map((person) => person.displayName).join(', ')); +const triggerLabel = $derived( + persons.length === 1 ? heading : m.search_disambiguation_trigger_label() +); async function openPicker() { open = true; @@ -64,7 +67,7 @@ function onKeydown(event: KeyboardEvent) { aria-haspopup="true" aria-expanded={open} aria-controls={panelId} - aria-label={m.search_disambiguation_trigger_label()} + aria-label={triggerLabel} onclick={toggle} class="inline-flex min-h-[44px] items-center gap-1.5 rounded-full border border-line bg-muted px-3 text-sm text-ink-2 outline-none focus-visible:ring-2 focus-visible:ring-brand-navy" > diff --git a/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts b/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts index 2f90f0aa..04eac3dd 100644 --- a/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts +++ b/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts @@ -78,7 +78,7 @@ describe('DisambiguationPicker', () => { showCue: false, onSelect: vi.fn() }); - await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click(); + await page.getByRole('button', { name: 'Meintest du Clara Cramer?' }).click(); await expect.element(page.getByText('Meintest du Clara Cramer?')).toBeVisible(); }); @@ -96,4 +96,23 @@ describe('DisambiguationPicker', () => { render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() }); await expect.element(page.getByText('(auswählen…)')).toBeVisible(); }); + + it('announces the did-you-mean heading as the trigger accessible name for a single suggestion', async () => { + render(DisambiguationPicker, { + persons: [{ id: 'c1', displayName: 'Clara Cramer' }], + heading: 'Meintest du Clara Cramer?', + showCue: false, + onSelect: vi.fn() + }); + await expect + .element(page.getByRole('button', { name: 'Meintest du Clara Cramer?' })) + .toBeInTheDocument(); + }); + + it('keeps the multiple-people trigger accessible name for two or more suggestions', async () => { + render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() }); + await expect + .element(page.getByRole('button', { name: /Mehrere Personen gefunden/ })) + .toBeInTheDocument(); + }); }); From 9d202b042b1864a9c13561e0b267ef3aec8a0d87 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 7 Jun 2026 01:29:29 +0200 Subject: [PATCH 28/29] test(person): close fetch-to-classify seam for alias matches on real Postgres (#763 review) AC#4 (maiden alias -> direct) and AC#5 (alias first name -> fetchable + classifiable) were each split across PersonRepositoryTest (the fetch) and PersonServiceTest (the classifier with stubs) -- nothing walked searchByName -> resolveByName end-to-end on real Postgres. Add two tests in the existing @DataJpaTest slice that build a real PersonService over the autowired repositories, persist a person with a MAIDEN_NAME alias and one with an alias firstName, and assert both classify as direct. Co-Authored-By: Claude Opus 4.8 --- .../person/PersonRepositoryTest.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java index 131bfb1d..48483476 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java @@ -453,6 +453,42 @@ class PersonRepositoryTest { .containsExactly("Anna", "Bernd", "Clara"); } + // ─── resolveByName fetch→classify, end-to-end on real Postgres (#763 review) ─── + // The classifier unit tests in PersonServiceTest stub searchByName, so they never prove the + // fetch query actually finds an alias-only match and feeds it into classification. These walk + // the whole searchByName → resolveByName path over the real Postgres slice, closing AC#4/#5. + + @Test + void resolveByName_maidenAlias_classifiesAsDirect_endToEnd() { + PersonService personService = new PersonService(personRepository, aliasRepository); + Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Müller").build()); + aliasRepository.save(PersonNameAlias.builder() + .person(clara).lastName("Cram").type(PersonNameAliasType.MAIDEN_NAME).sortOrder(0).build()); + // Detach so resolveByName re-fetches with its lazy nameAliases loaded from the DB — + // the fresh-session behaviour the @Transactional(readOnly=true) path has in production. + entityManager.flush(); + entityManager.clear(); + + NameMatches matches = personService.resolveByName("Clara Cram"); + + assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId()); + } + + @Test + void resolveByName_aliasFirstName_classifiesAsDirect_endToEnd() { + PersonService personService = new PersonService(personRepository, aliasRepository); + Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build()); + aliasRepository.save(PersonNameAlias.builder() + .person(clara).firstName("Wilhelmina").lastName("de Gruyter") + .type(PersonNameAliasType.BIRTH).sortOrder(0).build()); + entityManager.flush(); + entityManager.clear(); + + NameMatches matches = personService.resolveByName("Wilhelmina"); + + assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId()); + } + // ─── searchWithDocumentCount with aliases ──────────────────────────────── @Test From 09b77e9b3611b2f104cb232c131196dcd2dc2ec8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 7 Jun 2026 01:31:07 +0200 Subject: [PATCH 29/29] test(person): pin fetchPool dedup when one person matches two tokens (#763 review) Assert that when the same person id is returned by two different token fetches, the person appears exactly once in the result -- pinning fetchPool's putIfAbsent dedup so a future refactor can't silently double-classify a candidate. Co-Authored-By: Claude Opus 4.8 --- .../familienarchiv/person/PersonServiceTest.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java index 43afccd7..9cabe1ce 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java @@ -1045,4 +1045,18 @@ class PersonServiceTest { verify(personRepository, org.mockito.Mockito.atMost(8)).searchByName(any()); } + + @Test + void resolveByName_samePersonFromTwoTokens_appearsOnce() { + // Both token fetches return the same person id — fetchPool's putIfAbsent must dedup so the + // candidate is classified once, not twice. + Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build(); + when(personRepository.searchByName("clara")).thenReturn(List.of(clara)); + when(personRepository.searchByName("cram")).thenReturn(List.of(clara)); + + NameMatches result = personService.resolveByName("Clara Cram"); + + assertThat(result.direct()).hasSize(1); + assertThat(result.partial()).isEmpty(); + } }