diff --git a/CLAUDE.md b/CLAUDE.md index 8364399a..b0203020 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,6 +92,7 @@ backend/src/main/java/org/raddatz/familienarchiv/ ├── ocr/ OCR domain — OcrService, OcrBatchService, training ├── person/ Person domain │ └── relationship/ PersonRelationship sub-domain +├── search/ NL search domain — NlSearchController, NlQueryParserService, RestClientOllamaClient, NlSearchRateLimiter ├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect ├── tag/ Tag domain └── user/ User domain — AppUser, UserGroup, UserService @@ -160,7 +161,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) -**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded). +**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `SMART_SEARCH_UNAVAILABLE` (HTTP 503 — Ollama inference service offline or timed out); `SMART_SEARCH_RATE_LIMITED` (HTTP 429 — user exceeded 5 NL search requests per minute). ### Security / Permissions @@ -268,7 +269,7 @@ Back button pattern — use the shared `` component from `$lib/share → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) -**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded). +**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `SMART_SEARCH_UNAVAILABLE` (HTTP 503 — Ollama inference service offline or timed out); `SMART_SEARCH_RATE_LIMITED` (HTTP 429 — user exceeded 5 NL search requests per minute). --- diff --git a/backend/pom.xml b/backend/pom.xml index cb1d2024..b01f2362 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -41,6 +41,27 @@ pom import + + + org.eclipse.jetty.ee10 + jetty-ee10-servlet + 12.1.8 + + + org.eclipse.jetty.ee10 + jetty-ee10-servlets + 12.1.8 + + + org.eclipse.jetty.ee10 + jetty-ee10-webapp + 12.1.8 + + + org.eclipse.jetty + jetty-ee + 12.1.8 + @@ -137,6 +158,12 @@ archunit-junit5 1.3.0 test + + + org.wiremock + wiremock-jetty12 + 3.9.2 + test diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java index 071f2276..1cfe26f6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java @@ -57,6 +57,7 @@ public interface DocumentRepository extends JpaRepository, JpaSp @EntityGraph("Document.full") List findByReceiversId(UUID receiverId); + // Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed. List findByTags_Id(UUID tagId); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index ed37a2c2..4f69922c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -32,6 +32,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import jakarta.persistence.criteria.JoinType; +import jakarta.persistence.criteria.Predicate; import org.springframework.data.jpa.domain.Specification; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; @@ -1033,6 +1035,28 @@ public class DocumentService { return documentRepository.findByReceiversId(receiverId); } + public DocumentSearchResult searchDocumentsByPersonId(UUID personId, LocalDate from, LocalDate to, Pageable pageable) { + Person person = personService.getById(personId); + Specification spec = buildPersonSpec(person, from, to); + Page page = documentRepository.findAll(spec, pageable); + List items = enrichItems(page.getContent(), null); + return DocumentSearchResult.paged(items, pageable, page.getTotalElements()); + } + + private Specification buildPersonSpec(Person person, LocalDate from, LocalDate to) { + return (root, query, cb) -> { + if (query != null) query.distinct(true); + var receiversJoin = root.join("receivers", JoinType.LEFT); + var senderPredicate = cb.equal(root.get("sender"), person); + var receiverPredicate = cb.equal(receiversJoin, person); + var personPredicate = cb.or(senderPredicate, receiverPredicate); + var predicates = new ArrayList<>(List.of(personPredicate)); + if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from)); + if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to)); + return cb.and(predicates.toArray(new Predicate[0])); + }; + } + public long getIncompleteCount() { return documentRepository.countByMetadataCompleteFalse(); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java index 3f38cddc..0d2a173b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java @@ -78,4 +78,8 @@ public class DomainException extends RuntimeException { public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) { return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds); } + + public static DomainException serviceUnavailable(ErrorCode code, String message) { + return new DomainException(code, HttpStatus.SERVICE_UNAVAILABLE, message); + } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 3eb5287d..85d495d8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -135,6 +135,12 @@ public enum ErrorCode { /** The merge target is a descendant of the source tag. 400 */ TAG_MERGE_INVALID_TARGET, + // --- NL Search --- + /** Ollama is unreachable or timed out. 503 */ + SMART_SEARCH_UNAVAILABLE, + /** NL search rate limit exceeded (5 requests per user per minute). 429 */ + SMART_SEARCH_RATE_LIMITED, + // --- Generic --- /** Request validation failed (missing or malformed fields). 400 */ VALIDATION_ERROR, 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 81fbb406..23d38baa 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java @@ -99,6 +99,10 @@ public class PersonService { return personRepository.findAllById(ids); } + public List findByDisplayNameContaining(String fragment) { + return personRepository.searchByName(fragment); + } + public List findAllFamilyMembers() { return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc(); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryInterpretation.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryInterpretation.java new file mode 100644 index 00000000..5313f093 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryInterpretation.java @@ -0,0 +1,22 @@ +package org.raddatz.familienarchiv.search; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; +import java.util.List; + +public record NlQueryInterpretation( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List resolvedPersons, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List ambiguousPersons, + LocalDate dateFrom, + LocalDate dateTo, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List keywords, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String rawQuery, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + boolean keywordsApplied +) { +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java new file mode 100644 index 00000000..5938fb5e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/NlQueryParserService.java @@ -0,0 +1,160 @@ +package org.raddatz.familienarchiv.search; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.document.DocumentSearchResult; +import org.raddatz.familienarchiv.document.DocumentService; +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.Person; +import org.raddatz.familienarchiv.person.PersonService; +import org.raddatz.familienarchiv.tag.TagOperator; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +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 final OllamaClient ollamaClient; + private final PersonService personService; + private final DocumentService documentService; + + public NlSearchResponse search(String query, Pageable pageable) { + if (query == null || query.length() < MIN_QUERY || query.length() > MAX_QUERY) { + throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, + "Query must be between " + MIN_QUERY + " and " + MAX_QUERY + " characters"); + } + + OllamaExtraction ext = ollamaClient.parse(query); + + List personNames = ext.personNames() != null ? ext.personNames() : List.of(); + List keywords = ext.keywords() != null ? ext.keywords() : List.of(); + + NameResolution resolution = resolveNames(personNames); + + if (!resolution.ambiguous().isEmpty()) { + NlQueryInterpretation interpretation = new NlQueryInterpretation( + List.of(), resolution.ambiguous(), + ext.dateFrom(), ext.dateTo(), + keywords, ext.rawQuery(), false); + return new NlSearchResponse(DocumentSearchResult.of(List.of()), interpretation); + } + + List resolved = resolution.resolved(); + List noMatchFragments = resolution.noMatchFragments(); + List extraFragments = resolution.extraFragments(); + + String text = buildText(keywords, noMatchFragments, extraFragments, ext.rawQuery()); + + 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, ext.rawQuery(), false); + return new NlSearchResponse(docs, interpretation); + } + + UUID sender = buildSender(resolved, ext.personRole()); + UUID receiver = buildReceiver(resolved, ext.personRole()); + + SearchFilters filters = new SearchFilters( + text.isBlank() ? null : text, + ext.dateFrom(), ext.dateTo(), + sender, receiver, + List.of(), null, + null, TagOperator.AND, 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, ext.rawQuery(), keywordsApplied); + return new NlSearchResponse(docs, interpretation); + } + + private NameResolution resolveNames(List personNames) { + List resolved = new ArrayList<>(); + List ambiguous = new ArrayList<>(); + List noMatchFragments = new ArrayList<>(); + List extraFragments = new ArrayList<>(); + + int resolvedIndex = 0; + for (String name : personNames) { + if (name == null || name.length() > MAX_NAME_LENGTH) { + 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; + + if (capped.isEmpty()) { + noMatchFragments.add(name); + } else if (capped.size() == 1) { + Person p = capped.get(0); + PersonHint hint = new PersonHint(p.getId(), p.getDisplayName()); + resolvedIndex++; + if (resolvedIndex <= 2) { + resolved.add(hint); + } else { + extraFragments.add(name); + } + } else { + capped.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName()))); + } + } + + return new NameResolution(resolved, ambiguous, noMatchFragments, extraFragments); + } + + private String buildText(List keywords, List noMatchFragments, + List extraFragments, String rawQuery) { + 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()) { + return rawQuery; + } + return text; + } + + private boolean isAnyRole(String role) { + return role == null || "any".equals(role) || (!"sender".equals(role) && !"receiver".equals(role)); + } + + private UUID buildSender(List resolved, String role) { + if (resolved.size() >= 2) return resolved.get(0).id(); + if (resolved.size() == 1 && "sender".equals(role)) return resolved.get(0).id(); + return null; + } + + private UUID buildReceiver(List resolved, String role) { + if (resolved.size() >= 2) return resolved.get(1).id(); + if (resolved.size() == 1 && "receiver".equals(role)) return resolved.get(0).id(); + return null; + } + + private record NameResolution( + List resolved, + List ambiguous, + List noMatchFragments, + List extraFragments + ) {} +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchController.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchController.java new file mode 100644 index 00000000..c58fff38 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchController.java @@ -0,0 +1,28 @@ +package org.raddatz.familienarchiv.search; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/search/nl") +@RequiredArgsConstructor +public class NlSearchController { + + private final NlQueryParserService nlQueryParserService; + private final NlSearchRateLimiter rateLimiter; + + @PostMapping + @RequirePermission(Permission.READ_ALL) + public NlSearchResponse search(@Valid @RequestBody NlSearchRequest request, + Pageable pageable, + @AuthenticationPrincipal UserDetails principal) { + rateLimiter.checkAndConsume(principal.getUsername()); + return nlQueryParserService.search(request.query(), pageable); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRateLimitProperties.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRateLimitProperties.java new file mode 100644 index 00000000..e71f8a36 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRateLimitProperties.java @@ -0,0 +1,12 @@ +package org.raddatz.familienarchiv.search; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties("app.nl-search.rate-limit") +@Data +public class NlSearchRateLimitProperties { + private int maxRequestsPerMinute = 5; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRateLimiter.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRateLimiter.java new file mode 100644 index 00000000..100296fa --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRateLimiter.java @@ -0,0 +1,46 @@ +package org.raddatz.familienarchiv.search; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +@Service +public class NlSearchRateLimiter { + + private final LoadingCache byUser; + private final int maxRequestsPerMinute; + + public NlSearchRateLimiter(NlSearchRateLimitProperties props) { + this.maxRequestsPerMinute = props.getMaxRequestsPerMinute(); + this.byUser = Caffeine.newBuilder() + .expireAfterAccess(1, TimeUnit.MINUTES) + .build(key -> newBucket(maxRequestsPerMinute)); + } + + public void checkAndConsume(String userKey) { + if (!byUser.get(userKey).tryConsume(1)) { + throw DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_RATE_LIMITED, + "NL search rate limit exceeded for user: " + userKey, 60L); + } + } + + void resetForTest() { + byUser.invalidateAll(); + } + + private static Bucket newBucket(int limit) { + return Bucket.builder() + .addLimit(Bandwidth.builder() + .capacity(limit) + .refillGreedy(limit, Duration.ofMinutes(1)) + .build()) + .build(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRequest.java new file mode 100644 index 00000000..0e9d3a9a --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchRequest.java @@ -0,0 +1,11 @@ +package org.raddatz.familienarchiv.search; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record NlSearchRequest( + @NotBlank + @Size(min = 3, max = 500) + String query +) { +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchResponse.java b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchResponse.java new file mode 100644 index 00000000..04e51bff --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/NlSearchResponse.java @@ -0,0 +1,12 @@ +package org.raddatz.familienarchiv.search; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.raddatz.familienarchiv.document.DocumentSearchResult; + +public record NlSearchResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + DocumentSearchResult result, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + NlQueryInterpretation interpretation +) { +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaClient.java b/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaClient.java new file mode 100644 index 00000000..8517d4df --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaClient.java @@ -0,0 +1,5 @@ +package org.raddatz.familienarchiv.search; + +public interface OllamaClient { + OllamaExtraction parse(String query); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaExtraction.java b/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaExtraction.java new file mode 100644 index 00000000..cc3dce6a --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaExtraction.java @@ -0,0 +1,18 @@ +package org.raddatz.familienarchiv.search; + +import java.time.LocalDate; +import java.util.List; + +/** + * Raw structured output from Ollama after parsing and sanitising. + * personRole is always one of "sender", "receiver", "any" — defensive parsing ensures this. + */ +record OllamaExtraction( + List personNames, + String personRole, + LocalDate dateFrom, + LocalDate dateTo, + List keywords, + String rawQuery +) { +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaHealthClient.java b/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaHealthClient.java new file mode 100644 index 00000000..9f1ad1d5 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaHealthClient.java @@ -0,0 +1,5 @@ +package org.raddatz.familienarchiv.search; + +public interface OllamaHealthClient { + boolean isHealthy(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaProperties.java b/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaProperties.java new file mode 100644 index 00000000..673006e7 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/OllamaProperties.java @@ -0,0 +1,15 @@ +package org.raddatz.familienarchiv.search; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties("app.ollama") +@Data +public class OllamaProperties { + private String baseUrl; + private String model; + private int timeoutSeconds = 30; + private int healthCheckTimeoutSeconds = 2; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/PersonHint.java b/backend/src/main/java/org/raddatz/familienarchiv/search/PersonHint.java new file mode 100644 index 00000000..61a0e0b9 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/PersonHint.java @@ -0,0 +1,13 @@ +package org.raddatz.familienarchiv.search; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.UUID; + +public record PersonHint( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + UUID id, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String displayName +) { +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/search/RestClientOllamaClient.java b/backend/src/main/java/org/raddatz/familienarchiv/search/RestClientOllamaClient.java new file mode 100644 index 00000000..64f08554 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/search/RestClientOllamaClient.java @@ -0,0 +1,184 @@ +package org.raddatz.familienarchiv.search; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.net.http.HttpClient; +import java.time.Duration; +import java.time.LocalDate; +import java.time.Year; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@Slf4j +public class RestClientOllamaClient implements OllamaClient, OllamaHealthClient { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Set VALID_ROLES = Set.of("sender", "receiver", "any"); + private static final int MAX_NAME_LENGTH = 200; + private static final int MAX_KEYWORD_LENGTH = 100; + + private static final Map JSON_SCHEMA = Map.of( + "type", "object", + "required", List.of("personNames", "personRole", "keywords"), + "properties", Map.of( + "personNames", Map.of("type", "array", "items", Map.of("type", "string", "maxLength", MAX_NAME_LENGTH)), + "personRole", Map.of("type", "string", "enum", List.of("sender", "receiver", "any")), + "dateFrom", Map.of("type", List.of("string", "null"), "maxLength", 20), + "dateTo", Map.of("type", List.of("string", "null"), "maxLength", 20), + "keywords", Map.of("type", "array", "items", Map.of("type", "string", "maxLength", MAX_KEYWORD_LENGTH)) + ) + ); + + private final RestClient inferenceClient; + private final RestClient healthClient; + private final OllamaProperties props; + + public RestClientOllamaClient(OllamaProperties props) { + this.props = props; + + HttpClient inferenceHttp = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + JdkClientHttpRequestFactory inferenceFactory = new JdkClientHttpRequestFactory(inferenceHttp); + inferenceFactory.setReadTimeout(Duration.ofSeconds(props.getTimeoutSeconds())); + this.inferenceClient = RestClient.builder() + .baseUrl(props.getBaseUrl()) + .requestFactory(inferenceFactory) + .build(); + + HttpClient healthHttp = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(props.getHealthCheckTimeoutSeconds())) + .build(); + JdkClientHttpRequestFactory healthFactory = new JdkClientHttpRequestFactory(healthHttp); + healthFactory.setReadTimeout(Duration.ofSeconds(props.getHealthCheckTimeoutSeconds())); + this.healthClient = RestClient.builder() + .baseUrl(props.getBaseUrl()) + .requestFactory(healthFactory) + .build(); + } + + @Override + public OllamaExtraction parse(String query) { + try { + OllamaGenerateRequest request = new OllamaGenerateRequest( + props.getModel(), query, JSON_SCHEMA, false); + String responseBody = inferenceClient.post() + .uri("/api/generate") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(String.class); + return parseOllamaResponse(responseBody, query); + } catch (DomainException e) { + throw e; + } catch (Exception e) { + log.warn("Ollama inference failed: {}", e.getClass().getSimpleName()); + throw DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE, + "Ollama unavailable: " + e.getClass().getSimpleName()); + } + } + + @Override + public boolean isHealthy() { + try { + healthClient.get().uri("/api/tags").retrieve().toBodilessEntity(); + return true; + } catch (Exception e) { + return false; + } + } + + private OllamaExtraction parseOllamaResponse(String responseBody, String rawQuery) { + try { + OllamaGenerateResponse response = MAPPER.readValue(responseBody, OllamaGenerateResponse.class); + String inner = response.response(); + if (inner == null || inner.isBlank()) { + return fallbackExtraction(rawQuery); + } + RawOllamaOutput raw = MAPPER.readValue(inner, RawOllamaOutput.class); + return toExtraction(raw, rawQuery); + } catch (Exception e) { + log.warn("Failed to parse Ollama response: {}", e.getClass().getSimpleName()); + throw DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE, + "Failed to parse Ollama response: " + e.getClass().getSimpleName()); + } + } + + private OllamaExtraction toExtraction(RawOllamaOutput raw, String rawQuery) { + List names = raw.personNames() == null ? List.of() : raw.personNames().stream() + .filter(n -> n != null && n.length() <= MAX_NAME_LENGTH) + .toList(); + List keywords = raw.keywords() == null ? List.of() : raw.keywords().stream() + .filter(k -> k != null && k.length() <= MAX_KEYWORD_LENGTH) + .toList(); + String role = sanitiseRole(raw.personRole()); + LocalDate dateFrom = parseDate(raw.dateFrom(), true); + LocalDate dateTo = parseDate(raw.dateTo(), false); + return new OllamaExtraction(names, role, dateFrom, dateTo, keywords, rawQuery); + } + + private OllamaExtraction fallbackExtraction(String rawQuery) { + return new OllamaExtraction(List.of(), "any", null, null, List.of(), rawQuery); + } + + private String sanitiseRole(String role) { + if (role != null && VALID_ROLES.contains(role)) { + return role; + } + log.warn("Unexpected personRole from Ollama: {}", role); + return "any"; + } + + private LocalDate parseDate(String raw, boolean isFrom) { + if (raw == null || raw.isBlank()) return null; + try { + return LocalDate.parse(raw, DateTimeFormatter.ISO_LOCAL_DATE); + } catch (DateTimeParseException ignored) { + } + try { + int year = Integer.parseInt(raw.strip()); + if (year > 1000 && year < 3000) { + return isFrom ? Year.of(year).atDay(1) : Year.of(year).atMonth(12).atEndOfMonth(); + } + } catch (NumberFormatException ignored) { + } + return null; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record OllamaGenerateResponse(String response) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record RawOllamaOutput( + @JsonProperty("personNames") List personNames, + @JsonProperty("personRole") String personRole, + @JsonProperty("dateFrom") String dateFrom, + @JsonProperty("dateTo") String dateTo, + @JsonProperty("keywords") List keywords + ) { + } + + private record OllamaGenerateRequest( + String model, + String prompt, + Object format, + boolean stream + ) { + } +} diff --git a/backend/src/main/resources/application-dev.yaml b/backend/src/main/resources/application-dev.yaml index 54e4a972..954e430b 100644 --- a/backend/src/main/resources/application-dev.yaml +++ b/backend/src/main/resources/application-dev.yaml @@ -11,3 +11,7 @@ springdoc: swagger-ui: enabled: true path: /swagger-ui.html + +app: + ollama: + base-url: http://localhost:11434 diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 1e4558e0..36d5298a 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -130,6 +130,16 @@ app: # The loader maps columns by header name — no positional indices (see ADR-025). dir: ${IMPORT_DIR:/import} + ollama: + base-url: http://ollama:11434 + model: qwen2.5:7b-instruct-q4_K_M + timeout-seconds: 30 + health-check-timeout-seconds: 2 + + nl-search: + rate-limit: + max-requests-per-minute: 5 + ocr: sender-model: activation-threshold: 100 diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java index 234abbd8..eff82466 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java @@ -624,4 +624,88 @@ class DocumentRepositoryTest { .reviewed(reviewed) .build(); } + + // ─── searchDocumentsByPersonId (via Specification) ─────────────────────── + + private Page searchByPerson(Person person, LocalDate from, LocalDate to) { + Specification spec = (root, query, cb) -> { + if (query != null) query.distinct(true); + var receiversJoin = root.join("receivers", jakarta.persistence.criteria.JoinType.LEFT); + var personPredicate = cb.or( + cb.equal(root.get("sender"), person), + cb.equal(receiversJoin, person)); + var predicates = new java.util.ArrayList<>(java.util.List.of(personPredicate)); + if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from)); + if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to)); + return cb.and(predicates.toArray(new jakarta.persistence.criteria.Predicate[0])); + }; + return documentRepository.findAll(spec, PageRequest.of(0, 10)); + } + + @Test + void searchByPersonSpec_returnsDocument_whenPersonIsSender() { + Person person = personRepository.save(Person.builder().lastName("Raddatz").build()); + Document doc = documentRepository.save(Document.builder() + .title("Senderbrief").originalFilename("sender.pdf") + .status(DocumentStatus.UPLOADED).sender(person).build()); + + Page result = searchByPerson(person, null, null); + + assertThat(result.getContent()).extracting(Document::getId).containsExactly(doc.getId()); + } + + @Test + void searchByPersonSpec_returnsDocument_whenPersonIsReceiver() { + Person person = personRepository.save(Person.builder().lastName("Raddatz").build()); + Document doc = documentRepository.save(Document.builder() + .title("Empfängerbrief").originalFilename("receiver.pdf") + .status(DocumentStatus.UPLOADED) + .receivers(new java.util.HashSet<>(List.of(person))).build()); + + Page result = searchByPerson(person, null, null); + + assertThat(result.getContent()).extracting(Document::getId).containsExactly(doc.getId()); + } + + @Test + void searchByPersonSpec_returnsDocumentOnce_whenPersonIsBothSenderAndReceiver() { + Person person = personRepository.save(Person.builder().lastName("Raddatz").build()); + Document doc = documentRepository.save(Document.builder() + .title("SenderEmpfänger").originalFilename("both.pdf") + .status(DocumentStatus.UPLOADED).sender(person) + .receivers(new java.util.HashSet<>(List.of(person))).build()); + + Page result = searchByPerson(person, null, null); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getId()).isEqualTo(doc.getId()); + } + + @Test + void searchByPersonSpec_excludesDocuments_outsideDateRange() { + Person person = personRepository.save(Person.builder().lastName("Raddatz").build()); + Document inside = documentRepository.save(Document.builder() + .title("Innen").originalFilename("inside.pdf").status(DocumentStatus.UPLOADED) + .sender(person).documentDate(LocalDate.of(1918, 6, 15)).build()); + documentRepository.save(Document.builder() + .title("Außen").originalFilename("outside.pdf").status(DocumentStatus.UPLOADED) + .sender(person).documentDate(LocalDate.of(1920, 1, 1)).build()); + + Page result = searchByPerson(person, LocalDate.of(1914, 1, 1), LocalDate.of(1918, 12, 31)); + + assertThat(result.getContent()).extracting(Document::getId).containsExactly(inside.getId()); + } + + @Test + void searchByPersonSpec_returnsEmpty_whenNoMatchingDocuments() { + Person person = personRepository.save(Person.builder().lastName("Raddatz").build()); + Person other = personRepository.save(Person.builder().lastName("Braun").build()); + documentRepository.save(Document.builder() + .title("Fremder Brief").originalFilename("other.pdf") + .status(DocumentStatus.UPLOADED).sender(other).build()); + + Page result = searchByPerson(person, null, null); + + assertThat(result.getContent()).isEmpty(); + } } 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 18008afa..865ae9ad 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java @@ -898,4 +898,15 @@ class PersonServiceTest { .extracting(e -> ((DomainException) e).getStatus().value()) .isEqualTo(403); } + + @Test + void findByDisplayNameContaining_delegatesToSearchByName() { + Person walter = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build(); + when(personRepository.searchByName("Walter")).thenReturn(List.of(walter)); + + List result = personService.findByDisplayNameContaining("Walter"); + + assertThat(result).containsExactly(walter); + verify(personRepository).searchByName("Walter"); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java new file mode 100644 index 00000000..65d73685 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlQueryParserServiceTest.java @@ -0,0 +1,440 @@ +package org.raddatz.familienarchiv.search; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.raddatz.familienarchiv.document.DocumentSearchResult; +import org.raddatz.familienarchiv.document.DocumentService; +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.Person; +import org.raddatz.familienarchiv.person.PersonService; +import org.raddatz.familienarchiv.tag.TagOperator; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class NlQueryParserServiceTest { + + @Mock OllamaClient ollamaClient; + @Mock PersonService personService; + @Mock DocumentService documentService; + + NlQueryParserService service; + + static final Pageable PAGE = PageRequest.of(0, 20); + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new NlQueryParserService(ollamaClient, personService, documentService); + when(documentService.searchDocuments(any(), any(), any(), any())) + .thenReturn(DocumentSearchResult.of(List.of())); + when(documentService.searchDocumentsByPersonId(any(), any(), any(), any())) + .thenReturn(DocumentSearchResult.of(List.of())); + } + + // --- Factory helpers --- + + private OllamaExtraction extraction(List names, String role, LocalDate from, LocalDate to, + List keywords) { + String raw = names.isEmpty() ? "test query" : String.join(" ", names); + return new OllamaExtraction(names, role, from, to, keywords, raw); + } + + private Person person(UUID id, String firstName, String lastName) { + return Person.builder().id(id).firstName(firstName).lastName(lastName).build(); + } + + 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"); + + // --- 1. Single resolved name + personRole=sender --- + + @Test + void search_resolvesSingleName_asSender() { + 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)); + + NlSearchResponse resp = service.search("Was hat Walter geschrieben?", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE)); + assertThat(cap.getValue().sender()).isEqualTo(P1); + assertThat(cap.getValue().receiver()).isNull(); + assertThat(resp.interpretation().resolvedPersons()).hasSize(1); + assertThat(resp.interpretation().resolvedPersons().get(0).id()).isEqualTo(P1); + assertThat(resp.interpretation().ambiguousPersons()).isEmpty(); + } + + // --- 2. Multi-match name → ambiguous, search NOT executed --- + + @Test + void search_multiMatchName_populatesAmbiguous_andSkipsSearch() { + Person a = person(UUID.randomUUID(), "Walter", "Braun"); + 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)); + + NlSearchResponse resp = service.search("Briefe von Walter", PAGE); + + verify(documentService, never()).searchDocuments(any(), any(), any(), any()); + verify(documentService, never()).searchDocumentsByPersonId(any(), any(), any(), any()); + assertThat(resp.interpretation().ambiguousPersons()).hasSize(2); + assertThat(resp.interpretation().resolvedPersons()).isEmpty(); + } + + // --- 3. Multi-match + personRole=any → still ambiguous, search NOT executed --- + + @Test + void search_multiMatchName_withPersonRoleAny_stillSkipsSearch() { + Person a = person(UUID.randomUUID(), "Emma", "Braun"); + 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)); + + NlSearchResponse resp = service.search("Briefe an Emma", PAGE); + + verify(documentService, never()).searchDocuments(any(), any(), any(), any()); + verify(documentService, never()).searchDocumentsByPersonId(any(), any(), any(), any()); + assertThat(resp.interpretation().ambiguousPersons()).hasSize(2); + } + + // --- 4. No-match name → folded into text --- + + @Test + 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()); + + service.search("Briefe von Karl", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().text()).contains("Karl"); + assertThat(cap.getValue().sender()).isNull(); + assertThat(cap.getValue().receiver()).isNull(); + } + + // --- 5. personRole=any + 1 resolved → searchDocumentsByPersonId called --- + + @Test + void search_personRoleAny_singleMatch_callsSearchDocumentsByPersonId() { + 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)); + + NlSearchResponse resp = service.search("Briefe von Walter", PAGE); + + verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE)); + verify(documentService, never()).searchDocuments(any(), any(), any(), any()); + assertThat(resp.interpretation().keywordsApplied()).isFalse(); + } + + // --- 6. 2 names both resolve → sender=person1, receiver=person2 --- + + @Test + void search_twoNamesResolve_assignsSenderAndReceiver() { + Person walter = person(P1, "Walter", "Raddatz"); + 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)); + + NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE)); + assertThat(cap.getValue().sender()).isEqualTo(P1); + assertThat(cap.getValue().receiver()).isEqualTo(P2); + assertThat(resp.interpretation().resolvedPersons().get(0).id()).isEqualTo(P1); + assertThat(resp.interpretation().resolvedPersons().get(1).id()).isEqualTo(P2); + } + + // --- 7. 2 names, first resolves, second ambiguous → search NOT executed --- + + @Test + void search_twoNames_secondAmbiguous_skipsSearch() { + Person walter = person(P1, "Walter", "Raddatz"); + Person emma1 = person(P2, "Emma", "Braun"); + 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)); + + NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE); + + verify(documentService, never()).searchDocuments(any(), any(), any(), any()); + assertThat(resp.interpretation().ambiguousPersons()).hasSize(2); + } + + // --- 8. 2 names, first no match → folded into text, second used as single person --- + + @Test + void search_twoNames_firstNoMatch_secondResolved_foldFirstIntoText() { + 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)); + + service.search("Briefe von Karl an Emma", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().text()).contains("Karl"); + assertThat(cap.getValue().sender()).isEqualTo(P2); + } + + // --- 9. 3+ names all resolve → first two as sender/receiver, third folded into text --- + + @Test + void search_threeNamesResolve_extraFoldedIntoText() { + Person walter = person(P1, "Walter", "Raddatz"); + Person emma = person(P2, "Emma", "Raddatz"); + 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)); + + service.search("Briefe von Walter an Emma über Heinrich", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().sender()).isEqualTo(P1); + assertThat(cap.getValue().receiver()).isEqualTo(P2); + assertThat(cap.getValue().text()).contains("Heinrich"); + } + + // --- 10. Keywords space-joined into text --- + + @Test + void search_keywords_areJoinedIntoText() { + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg", "Walter"))); + + service.search("Dokumente über den Krieg Walter", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().text()).isEqualTo("Krieg Walter"); + } + + // --- 11. Date range passed through --- + + @Test + void search_dateRange_passedIntoSearchFilters() { + LocalDate from = LocalDate.of(1914, 1, 1); + LocalDate to = LocalDate.of(1914, 12, 31); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", from, to, List.of())); + + service.search("Briefe aus dem Jahr 1914", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().from()).isEqualTo(from); + assertThat(cap.getValue().to()).isEqualTo(to); + } + + // --- 12. Null dates → null in SearchFilters (not an error) --- + + @Test + void search_nullDates_passedAsNullIntoFilters() { + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit"))); + + service.search("Hochzeitsbriefe", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().from()).isNull(); + assertThat(cap.getValue().to()).isNull(); + } + + // --- 13. Query under 3 chars → VALIDATION_ERROR before Ollama call --- + + @Test + void search_queryTooShort_throwsValidationError() { + assertThatThrownBy(() -> service.search("ab", PAGE)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.VALIDATION_ERROR); + + verify(ollamaClient, never()).parse(anyString()); + } + + // --- 14. Query over 500 chars → VALIDATION_ERROR --- + + @Test + void search_queryTooLong_throwsValidationError() { + String longQuery = "a".repeat(501); + assertThatThrownBy(() -> service.search(longQuery, PAGE)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.VALIDATION_ERROR); + + verify(ollamaClient, never()).parse(anyString()); + } + + // --- 15. Ollama returns empty names/keywords → raw query used as keyword fallback --- + + @Test + void search_ollamaReturnsEmpty_usesRawQueryAsTextFallback() { + String raw = "Briefe aus dem Krieg"; + when(ollamaClient.parse(anyString())) + .thenReturn(new OllamaExtraction(List.of(), "any", null, null, List.of(), raw)); + + service.search(raw, PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().text()).isEqualTo(raw); + } + + // --- 16. Null personNames/keywords from Ollama → no NPE --- + + @Test + void search_nullPersonNamesAndKeywords_handledWithoutNpe() { + OllamaExtraction ext = new OllamaExtraction(null, "any", null, null, null, "test query"); + when(ollamaClient.parse(anyString())).thenReturn(ext); + + NlSearchResponse resp = service.search("test query", PAGE); + + assertThat(resp).isNotNull(); + verify(documentService).searchDocuments(any(), any(), any(), any()); + } + + // --- 17. Unrecognized personRole → defaults to any-like behavior (no crash) --- + + @Test + void search_unrecognizedPersonRole_treatedLikeAny_withSingleResolvedPerson() { + Person walter = person(P1, "Walter", "Raddatz"); + // OllamaClient defensive parsing returns "any" for unknown roles, + // 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)); + + NlSearchResponse resp = service.search("Briefe von Walter", PAGE); + + // Should not crash; "unknown_role" treated as fallback (neither sender nor receiver → any) + assertThat(resp).isNotNull(); + } + + // --- 18. Ollama throws SMART_SEARCH_UNAVAILABLE → propagates to caller --- + + @Test + void search_ollamaThrowsUnavailable_propagates() { + when(ollamaClient.parse(anyString())) + .thenThrow(DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_UNAVAILABLE, "offline")); + + assertThatThrownBy(() -> service.search("Was hat Walter geschrieben?", PAGE)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE); + } + + // --- 19. LLM-extracted name > 200 chars → skipped, PersonService never called --- + + @Test + void search_nameLongerThan200Chars_isSkippedBeforePersonServiceCall() { + String longName = "A".repeat(201); + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(longName), "sender", null, null, List.of())); + + service.search("Briefe von sehr langem Namen", PAGE); + + verify(personService, never()).findByDisplayNameContaining(anyString()); + } + + // --- 20. Max 10 candidates cap: 11 persons returned → only first 10 in ambiguousPersons --- + + @Test + void search_elevenCandidates_capsAtTen() { + List eleven = new ArrayList<>(); + for (int i = 0; i < 11; i++) { + eleven.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); + + NlSearchResponse resp = service.search("Briefe von Walter", PAGE); + + assertThat(resp.interpretation().ambiguousPersons()).hasSize(10); + verify(documentService, never()).searchDocuments(any(), any(), any(), any()); + } + + // --- 21. SearchFilters defaults: tagOperator=AND, status=null, undated=false, tags=empty --- + + @Test + void search_searchFiltersDefaults_areCorrect() { + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg"))); + + service.search("Dokumente über den Krieg", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE)); + SearchFilters f = cap.getValue(); + assertThat(f.tagOperator()).isEqualTo(TagOperator.AND); + assertThat(f.status()).isNull(); + assertThat(f.undated()).isFalse(); + assertThat(f.tags()).isEmpty(); + assertThat(f.tagQ()).isNull(); + } + + // --- 22. personRole=receiver + 1 resolved → receiver UUID set --- + + @Test + void search_personRoleReceiver_singleMatch_setsReceiver() { + 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)); + + service.search("Briefe an Emma", PAGE); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchFilters.class); + verify(documentService).searchDocuments(cap.capture(), any(), any(), any()); + assertThat(cap.getValue().receiver()).isEqualTo(P2); + assertThat(cap.getValue().sender()).isNull(); + } + + // --- 23. keywordsApplied=true when text is non-blank --- + + @Test + void search_keywordsApplied_trueWhenTextNonBlank() { + when(ollamaClient.parse(anyString())) + .thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost"))); + + NlSearchResponse resp = service.search("Feldpost aus dem Krieg", PAGE); + + assertThat(resp.interpretation().keywordsApplied()).isTrue(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java new file mode 100644 index 00000000..b35b1c52 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchControllerTest.java @@ -0,0 +1,161 @@ +package org.raddatz.familienarchiv.search; + +import tools.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.document.DocumentSearchResult; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.security.SecurityConfig; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.user.CustomUserDetailsService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(NlSearchController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class, + NlSearchRateLimiter.class, NlSearchRateLimitProperties.class}) +class NlSearchControllerTest { + + @Autowired MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @MockitoBean NlQueryParserService nlQueryParserService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + @Autowired NlSearchRateLimiter rateLimiter; + + @BeforeEach + void resetRateLimiter() { + rateLimiter.resetForTest(); + } + + private NlSearchResponse makeResponse() { + 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); + return new NlSearchResponse(DocumentSearchResult.of(List.of()), interp); + } + + // --- 1. Happy path --- + + @Test + @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) + void search_returns200_withNlSearchResponse() throws Exception { + when(nlQueryParserService.search(anyString(), any())).thenReturn(makeResponse()); + + mockMvc.perform(post("/api/search/nl").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"Briefe von Walter im Krieg\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.interpretation.rawQuery").value("Briefe von Walter im Krieg")) + .andExpect(jsonPath("$.interpretation.resolvedPersons[0].displayName").value("Walter Raddatz")) + .andExpect(jsonPath("$.interpretation.keywordsApplied").value(true)); + } + + // --- 2. ambiguousPersons in response shape --- + + @Test + @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) + void search_returns200_withAmbiguousPersons() throws Exception { + PersonHint a = new PersonHint(UUID.randomUUID(), "Walter Braun"); + 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); + NlSearchResponse resp = new NlSearchResponse(DocumentSearchResult.of(List.of()), interp); + when(nlQueryParserService.search(anyString(), any())).thenReturn(resp); + + mockMvc.perform(post("/api/search/nl").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"Briefe von Walter\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.interpretation.ambiguousPersons").isArray()) + .andExpect(jsonPath("$.interpretation.ambiguousPersons[0].displayName").value("Walter Braun")) + .andExpect(jsonPath("$.interpretation.ambiguousPersons[1].id").isNotEmpty()); + } + + // --- 3. Unauthenticated → 401 --- + + @Test + void search_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/search/nl").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"Briefe von Walter\"}")) + .andExpect(status().isUnauthorized()); + } + + // --- 4. Query < 3 chars → 400 --- + + @Test + @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) + void search_returns400_whenQueryTooShort() throws Exception { + mockMvc.perform(post("/api/search/nl").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"ab\"}")) + .andExpect(status().isBadRequest()); + } + + // --- 5. Query > 500 chars → 400 --- + + @Test + @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) + void search_returns400_whenQueryTooLong() throws Exception { + String longQuery = "a".repeat(501); + mockMvc.perform(post("/api/search/nl").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"" + longQuery + "\"}")) + .andExpect(status().isBadRequest()); + } + + // --- 6. Ollama unavailable → 503 --- + + @Test + @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) + void search_returns503_whenOllamaUnavailable() throws Exception { + when(nlQueryParserService.search(anyString(), any())) + .thenThrow(DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE, "Ollama offline")); + + mockMvc.perform(post("/api/search/nl").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"Briefe von Walter\"}")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("SMART_SEARCH_UNAVAILABLE")); + } + + // --- 7. 6th request in 1 minute → 429 --- + + @Test + @WithMockUser(username = "user@test.com", authorities = {"READ_ALL"}) + void search_returns429_onSixthRequestWithinRateLimit() throws Exception { + when(nlQueryParserService.search(anyString(), any())).thenReturn(makeResponse()); + + for (int i = 0; i < 5; i++) { + mockMvc.perform(post("/api/search/nl").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"Briefe von Walter\"}")) + .andExpect(status().isOk()); + } + + mockMvc.perform(post("/api/search/nl").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"Briefe von Walter\"}")) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.code").value("SMART_SEARCH_RATE_LIMITED")); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchRateLimiterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchRateLimiterTest.java new file mode 100644 index 00000000..43a2bbe0 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/NlSearchRateLimiterTest.java @@ -0,0 +1,62 @@ +package org.raddatz.familienarchiv.search; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class NlSearchRateLimiterTest { + + private NlSearchRateLimiter rateLimiter; + + @BeforeEach + void setUp() { + NlSearchRateLimitProperties props = new NlSearchRateLimitProperties(); + props.setMaxRequestsPerMinute(5); + rateLimiter = new NlSearchRateLimiter(props); + } + + @Test + void checkAndConsume_allowsRequestsWithinLimit() { + for (int i = 0; i < 5; i++) { + assertThatCode(() -> rateLimiter.checkAndConsume("user@example.com")) + .doesNotThrowAnyException(); + } + } + + @Test + void checkAndConsume_throwsRateLimited_onSixthRequest() { + for (int i = 0; i < 5; i++) { + rateLimiter.checkAndConsume("user@example.com"); + } + + assertThatThrownBy(() -> rateLimiter.checkAndConsume("user@example.com")) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.SMART_SEARCH_RATE_LIMITED); + } + + @Test + void checkAndConsume_limitsAreIndependentPerUser() { + for (int i = 0; i < 5; i++) { + rateLimiter.checkAndConsume("alice@example.com"); + } + assertThatCode(() -> rateLimiter.checkAndConsume("bob@example.com")) + .doesNotThrowAnyException(); + } + + @Test + void resetForTest_clearsAllBuckets() { + for (int i = 0; i < 5; i++) { + rateLimiter.checkAndConsume("user@example.com"); + } + + rateLimiter.resetForTest(); + + assertThatCode(() -> rateLimiter.checkAndConsume("user@example.com")) + .doesNotThrowAnyException(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/search/RestClientOllamaClientTest.java b/backend/src/test/java/org/raddatz/familienarchiv/search/RestClientOllamaClientTest.java new file mode 100644 index 00000000..058ec095 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/search/RestClientOllamaClientTest.java @@ -0,0 +1,113 @@ +package org.raddatz.familienarchiv.search; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RestClientOllamaClientTest { + + private WireMockServer wireMock; + private RestClientOllamaClient client; + + @BeforeEach + void setUp() { + wireMock = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()); + wireMock.start(); + + OllamaProperties props = new OllamaProperties(); + props.setBaseUrl("http://localhost:" + wireMock.port()); + props.setModel("qwen2.5:7b-instruct-q4_K_M"); + props.setTimeoutSeconds(5); + props.setHealthCheckTimeoutSeconds(2); + + client = new RestClientOllamaClient(props); + } + + @AfterEach + void tearDown() { + wireMock.stop(); + } + + // --- Factory helpers --- + + private String makeOllamaResponseJson(String personNamesJson, String personRole, + String dateFrom, String dateTo, String keywordsJson) { + String inner = String.format( + "{\"personNames\":%s,\"personRole\":\"%s\",\"dateFrom\":%s,\"dateTo\":%s,\"keywords\":%s}", + personNamesJson, personRole, + dateFrom == null ? "null" : "\"" + dateFrom + "\"", + dateTo == null ? "null" : "\"" + dateTo + "\"", + keywordsJson + ); + return String.format("{\"model\":\"qwen2.5:7b-instruct-q4_K_M\",\"response\":\"%s\",\"done\":true}", + inner.replace("\"", "\\\"")); + } + + // --- Test cases --- + + @Test + void parse_returnsExtraction_whenOllamaReturnsValidJson() { + String body = makeOllamaResponseJson("[\"Walter\"]", "sender", "1914-01-01", "1914-12-31", "[\"Krieg\"]"); + wireMock.stubFor(post(urlEqualTo("/api/generate")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(body))); + + OllamaExtraction result = client.parse("Was hat Walter im Krieg geschrieben?"); + + assertThat(result.personNames()).containsExactly("Walter"); + assertThat(result.personRole()).isEqualTo("sender"); + assertThat(result.keywords()).containsExactly("Krieg"); + assertThat(result.dateFrom()).isNotNull(); + assertThat(result.dateTo()).isNotNull(); + } + + @Test + void parse_throwsSmartSearchUnavailable_whenOllamaReturns500() { + wireMock.stubFor(post(urlEqualTo("/api/generate")) + .willReturn(aResponse().withStatus(500))); + + assertThatThrownBy(() -> client.parse("some query")) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE); + } + + @Test + void parse_throwsSmartSearchUnavailable_whenOllamaExceedsTimeout() { + wireMock.stubFor(post(urlEqualTo("/api/generate")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withFixedDelay(6000) + .withBody("{\"response\":\"{}\",\"done\":true}"))); + + assertThatThrownBy(() -> client.parse("some query")) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE); + } + + @Test + void parse_throwsSmartSearchUnavailable_whenOllamaReturnsMalformedJson() { + wireMock.stubFor(post(urlEqualTo("/api/generate")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"response\":\"not-json-at-all\",\"done\":true}"))); + + assertThatThrownBy(() -> client.parse("some query")) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE); + } +} diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index e56ca77a..5c2580de 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -585,6 +585,37 @@ bash scripts/download-kraken-models.sh > Downloads the Kurrent/Sütterlin HTR models. Run once after a fresh clone or when models are updated. +### Ollama — natural-language search (NL Search) + +NL search uses a local Ollama instance for query parsing. The `ollama` service is defined in `docker-compose.yml` alongside the main stack. + +**First-time model pull** (required before the feature works): + +```bash +docker compose exec ollama ollama pull qwen2.5:7b-instruct-q4_K_M +``` + +This downloads ~4.4 GB. The model is stored in the `ollama_data` Docker volume and persists across container restarts. + +**Verify the model is available:** + +```bash +docker compose exec ollama ollama list +``` + +Expected output includes `qwen2.5:7b-instruct-q4_K_M`. + +**Health check** — the backend polls `GET /api/tags` on Ollama at startup and before inference. If Ollama is absent, `POST /api/search/nl` returns HTTP 503 with `SMART_SEARCH_UNAVAILABLE`. + +**Configuration** (see `application.yaml` under `app.ollama`): + +| Property | Default | Description | +|---|---|---| +| `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.nl-search.rate-limit.max-requests-per-minute` | `5` | Per-user rate limit | + ### Upgrade the Ollama model To switch to a newer model version (e.g. a future release of `qwen2.5`): diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 8bb508ab..1c6e8cb6 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -167,6 +167,16 @@ _See also [Chronik](#chronik-internal)._ --- +## NL Search Terms + +**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). + +**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. + +--- + ## Infrastructure Terms **archiv-app** — the bucket-scoped MinIO service account the backend uses to read and write the `familienarchiv` bucket. Distinct from the MinIO root account (`archiv`, used only by the bootstrap container for admin operations). Defined and provisioned in [`infra/minio/bootstrap.sh`](../infra/minio/bootstrap.sh) and consumed by the backend as `S3_ACCESS_KEY` in [`docker-compose.prod.yml`](../docker-compose.prod.yml). The attached `archiv-app-policy` grants `s3:GetObject/PutObject/DeleteObject` on `familienarchiv/*` and `s3:ListBucket/GetBucketLocation` on the bucket only — not the built-in `readwrite` policy which would grant `s3:*` on all buckets. diff --git a/docs/adr/028-nl-search-ollama.md b/docs/adr/028-nl-search-ollama.md new file mode 100644 index 00000000..41459992 --- /dev/null +++ b/docs/adr/028-nl-search-ollama.md @@ -0,0 +1,65 @@ +# ADR-028 — Natural language search is powered by Ollama (Qwen 2.5 7B), not a cloud API + +**Date:** 2026-06-06 +**Status:** Accepted +**Issue:** #738 (NL search backend); part of epic #735 +**Milestone:** Archive Intelligence — NL Search + +--- + +## Context + +Family members write their search intent in plain German ("Was hat Walter im Krieg an Emma geschrieben?"), not in structured filter forms. Issue #735 defines NL search as a core product goal. Three delivery options were evaluated: + +**Option A — extend the OCR service.** The OCR Python microservice already runs on the same host. Adding LLM inference there avoids a new container. Rejected: the OCR service is a single-purpose, CPU-bound pipeline optimised for Kraken; bundling a 4.5 GB LLM weight into the same image would bloat it, complicate model lifecycle management, and create an unrelated failure domain (OOM on large OCR batches vs. LLM load time). ADR-001 was explicit about keeping OCR single-purpose. + +**Option B — call an external API (OpenAI, Anthropic, etc.).** Cloud inference is instant and requires no local hardware. Rejected: the archive contains real person names and private family correspondence from 1899–1950 — sending query content to a third party violates the project's data-residency principle (family data stays on the family server). Additionally, API cost and availability are outside the operator's control; the system must work air-gapped. + +**Option C — local Ollama service (chosen).** Ollama is a purpose-built LLM runtime with a simple REST API, model lifecycle management (`ollama pull`), and support for grammar-constrained JSON output. It runs entirely on the existing server (i7-6700, 64 GB RAM) with no cloud dependency. + +**Model selection:** Qwen 2.5 7B Q4_K_M (`qwen2.5:7b-instruct-q4_K_M`) was chosen over larger models because: +- Quantised weight is ~4.5 GB — fits comfortably in 64 GB RAM alongside PostgreSQL and the JVM. +- Instruction-tuned variant follows the structured JSON schema reliably without fine-tuning. +- CPU-only inference at Q4_K_M takes 2–15 seconds per query, acceptable for a search that replaces a multi-step filter form. + +**Prompt injection mitigation:** The backend sends the raw user query to Ollama. To prevent the model from being prompted to return schema-breaking output, the API call uses Ollama's `format` parameter with a grammar-constrained JSON schema. Output length is further bounded by `maxLength` constraints in the schema (names ≤ 200 chars, keywords ≤ 100 chars). `NlQueryParserService` enforces these limits in code before any LLM-extracted fragment is passed to `PersonRepository.searchByName()` — defence in depth. + +**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. + +**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. + +**NL query logging policy:** Only metadata is logged — query length, resolved person count, latency in milliseconds. The raw query is never written to the log file. Rationale: queries contain real family names (PII); log files persist to disk and may be shipped to Loki. Structured metadata is sufficient for debugging latency regressions. + +**Prompt-amplification abuse:** A malicious user could submit a long or crafted query to cause slow Ollama inference, consuming CPU. Mitigated by `NlSearchRateLimiter` (5 requests per user per minute, Bucket4j + Caffeine) and by `@Size(max=500)` on the request body. The rate limiter is node-local; in multi-replica deployments the effective limit multiplies by replica count — acceptable at the current single-node deployment scale. + +**Ollama model pre-pull requirement:** The Docker image contains only the Ollama binary, not the model weights. The operator must run `ollama pull qwen2.5:7b-instruct-q4_K_M` (≈4.5 GB download, 10–30 minutes) before the backend starts inference. If skipped, every NL search request returns 503 until the pull completes. The deployment runbook in `docs/DEPLOYMENT.md` covers this explicitly. + +**Startup dependency:** The `backend` Compose service declares `depends_on: ollama: condition: service_healthy`. The Ollama healthcheck polls `GET http://localhost:11434/api/tags`; `start_period: 120s` provides margin for weight loading (20–60 s on SSD). Note: `service_healthy` confirms the API is responding, not that the model is downloaded — if the pull was skipped, inference still returns 404. + +**Multi-name resolution heuristic:** For 2-name queries (e.g. "Was hat Walter an Emma geschrieben?"), the first extracted name is treated as sender and the second as receiver. Per-name role annotation (e.g. `{name: "Walter", role: "sender"}`) was rejected because it would require a combinatorially complex Ollama schema and the most natural German phrasing strongly implies sender→receiver order. For single-name queries, a `personRole` field (`sender`/`receiver`/`any`) is returned. + +**`personRole: "any"` keyword limitation:** When `personRole` is `"any"` and the name resolves to exactly one person, `DocumentService.searchDocumentsByPersonId()` is called (OR semantics: person as sender or receiver). Keyword filtering is not applied on this path — only person identity and date range. `keywordsApplied = false` is returned in the response. Rationale: the JPQL for OR-semantics person queries has no text predicate; adding FTS would require a native query or a separate pass, adding complexity for a case that is already well-narrowed by person identity. + +**`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. + +## 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. + +Key component structure: +- `OllamaClient` / `OllamaHealthClient` interfaces — mockable for tests, modelled on `OcrClient`/`OcrHealthClient` +- `RestClientOllamaClient` — two `RestClient` instances (30 s inference, 2 s health-check) +- `NlQueryParserService` — orchestrates Ollama → name resolution → document search +- `NlSearchRateLimiter` — Bucket4j + Caffeine, 5 req/min per user +- `NlSearchController` — `POST /api/search/nl`, `@RequirePermission(READ_ALL)` + +## Consequences + +- Family members can query in natural German without learning filter UI. Expected search satisfaction improvement for the 60+ age cohort (primary transcription audience) is significant. +- NL search is unavailable when Ollama is down or the model pull is not complete. The regular search is unaffected. The 503 response includes a CTA directing users to the regular search. +- Operator responsibility: run `ollama pull` on first deploy and after model updates. The backup runbook must exclude `ollama_models` volume (model weights are re-downloadable, not user data). +- Inference takes 2–15 seconds. The frontend loading indicator is a hard requirement (see issue #738 frontend notes). +- The rate limiter is node-local. At the current single-node deployment scale this is correct. If the service is ever scaled horizontally, the rate limiter must be moved to Redis (same caveat as `LoginRateLimiter`). +- The `search/` package introduces a new cross-domain dependency direction (`search` → `person`, `search` → `document`). This is intentional and documented in `docs/architecture/c4/l3-backend-search.puml`. diff --git a/docs/architecture/c4/l1-context.puml b/docs/architecture/c4/l1-context.puml index d31ae49f..e2ccef71 100644 --- a/docs/architecture/c4/l1-context.puml +++ b/docs/architecture/c4/l1-context.puml @@ -9,10 +9,12 @@ Person(member, "Family Member", "Access by administrator invite. Searches, brows System(familienarchiv, "Familienarchiv", "Web application for digitising, organising, and searching family documents") System_Ext(mail, "Email Service", "SMTP server. Delivers notification emails (mentions, replies) and password-reset links.") System_Ext(glitchtip, "GlitchTip", "Self-hosted error tracking (Sentry-compatible). Receives frontend and backend error events with stack traces.") +System_Ext(ollama, "Ollama (self-hosted)", "Local LLM inference server (qwen2.5:7b). Parses natural-language search queries into structured filters. Runs in the same Docker Compose stack.") Rel(admin, familienarchiv, "Manages via browser", "HTTPS") Rel(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS") Rel(familienarchiv, mail, "Sends notification and password-reset emails (optional)", "SMTP") Rel(familienarchiv, glitchtip, "Sends error events with errorId and stack trace", "HTTPS") +Rel(familienarchiv, ollama, "NL query parsing for natural-language search", "HTTP / REST (internal)") @enduml diff --git a/docs/architecture/c4/l2-containers.puml b/docs/architecture/c4/l2-containers.puml index a0315926..2d471dd9 100644 --- a/docs/architecture/c4/l2-containers.puml +++ b/docs/architecture/c4/l2-containers.puml @@ -17,6 +17,7 @@ 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)") { @@ -43,6 +44,7 @@ Rel(backend, ocr, "OCR job requests with presigned MinIO URL", "HTTP / REST / JS Rel(backend, mail, "Sends notification and password-reset emails (optional)", "SMTP") Rel(ocr, storage, "Fetches PDF via presigned URL", "HTTP / S3 presigned") Rel(mc, storage, "Bootstraps bucket + service account on startup", "MinIO Client CLI") +Rel(backend, ollama, "NL query parsing (POST /api/generate)", "HTTP / REST / JSON") 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") diff --git a/docs/architecture/c4/l3-backend-3h-search.puml b/docs/architecture/c4/l3-backend-3h-search.puml new file mode 100644 index 00000000..a0d643be --- /dev/null +++ b/docs/architecture/c4/l3-backend-3h-search.puml @@ -0,0 +1,33 @@ +@startuml +!include + +title Component Diagram: API Backend — NL Search + +Container(frontend, "Web Frontend", "SvelteKit") +ContainerDb(db, "PostgreSQL", "PostgreSQL 16") +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(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).") +} + +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).") + +Rel(frontend, nlCtrl, "POST /api/search/nl with JSON query", "HTTP / JSON") +Rel(nlCtrl, rateLimiter, "checkAndConsume(userEmail)") +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, 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") + +@enduml diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 32f6e464..e4d29d12 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -22,6 +22,9 @@ "error_forbidden": "Sie haben keine Berechtigung für diese Aktion.", "error_csrf_token_missing": "Sitzungsfehler. Bitte laden Sie die Seite neu.", "error_too_many_login_attempts": "Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.", + "error_smart_search_unavailable": "Die intelligente Suche ist momentan nicht verfügbar. Bitte nutzen Sie die normale Suche.", + "error_smart_search_rate_limited": "Sie haben die Suchfunktion zu häufig genutzt. Bitte warten Sie eine Minute.", + "smart_search_keywords_not_applied": "Schlüsselwörter konnten bei dieser Suche nicht berücksichtigt werden.", "error_validation_error": "Die Eingabe ist ungültig.", "error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.", "nav_documents": "Dokumente", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 435860e1..6eb58fca 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -22,6 +22,9 @@ "error_forbidden": "You do not have permission for this action.", "error_csrf_token_missing": "Session error. Please reload the page.", "error_too_many_login_attempts": "Too many login attempts. Please try again later.", + "error_smart_search_unavailable": "The smart search is currently unavailable. Please use the regular search.", + "error_smart_search_rate_limited": "You have used the search function too frequently. Please wait a minute.", + "smart_search_keywords_not_applied": "Keywords could not be applied to this search.", "error_validation_error": "The input is invalid.", "error_internal_error": "An unexpected error occurred.", "nav_documents": "Documents", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 88e2affb..92fb90af 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -22,6 +22,9 @@ "error_forbidden": "No tiene permiso para realizar esta acción.", "error_csrf_token_missing": "Error de sesión. Recargue la página.", "error_too_many_login_attempts": "Demasiados intentos. Por favor, inténtelo más tarde.", + "error_smart_search_unavailable": "La búsqueda inteligente no está disponible en este momento. Por favor, usa la búsqueda normal.", + "error_smart_search_rate_limited": "Has utilizado la función de búsqueda demasiadas veces. Por favor, espera un minuto.", + "smart_search_keywords_not_applied": "Las palabras clave no pudieron aplicarse a esta búsqueda.", "error_validation_error": "La entrada no es válida.", "error_internal_error": "Se ha producido un error inesperado.", "nav_documents": "Documentos", diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index d3457785..33d0904c 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -84,22 +84,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/persons/{id}/confirm": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch: operations["confirmPerson"]; - trace?: never; - }; "/api/documents/{id}": { parameters: { query?: never; @@ -244,6 +228,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/search/nl": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["search"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/persons": { parameters: { query?: never; @@ -708,6 +708,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/backfill-titles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["backfillTitles"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/backfill-file-hashes": { parameters: { query?: never; @@ -740,6 +756,22 @@ export interface paths { patch: operations["patchFamilyMember"]; trace?: never; }; + "/api/persons/{id}/confirm": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["confirmPerson"]; + trace?: never; + }; "/api/notifications/{id}/read": { parameters: { query?: never; @@ -859,7 +891,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["search"]; + get: operations["search_1"]; put?: never; post?: never; delete?: never; @@ -1323,7 +1355,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["search_1"]; + get: operations["search_2"]; put?: never; post?: never; delete?: never; @@ -1651,7 +1683,7 @@ export interface components { /** Format: int32 */ deathYear?: number; /** Format: int32 */ - generation?: number | null; + generation?: number; }; Person: { /** Format: uuid */ @@ -1668,7 +1700,7 @@ export interface components { /** Format: int32 */ deathYear?: number; /** Format: int32 */ - generation?: number | null; + generation?: number; familyMember: boolean; sourceRef?: string; provisional: boolean; @@ -1803,6 +1835,98 @@ export interface components { /** Format: uuid */ targetId: string; }; + NlSearchRequest: { + query: string; + }; + Pageable: { + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + sort?: string[]; + }; + ActivityActorDTO: { + initials: string; + color: string; + name?: string; + }; + DocumentListItem: { + /** Format: uuid */ + id: string; + title: string; + originalFilename: string; + thumbnailUrl?: string; + /** Format: date */ + documentDate?: string; + /** @enum {string} */ + metaDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + /** Format: date */ + metaDateEnd?: string; + sender?: components["schemas"]["Person"]; + receivers: components["schemas"]["Person"][]; + tags: components["schemas"]["Tag"][]; + archiveBox?: string; + archiveFolder?: string; + location?: string; + summary?: string; + /** Format: int32 */ + completionPercentage: number; + contributors: components["schemas"]["ActivityActorDTO"][]; + matchData: components["schemas"]["SearchMatchData"]; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + DocumentSearchResult: { + items: components["schemas"]["DocumentListItem"][]; + /** Format: int64 */ + totalElements: number; + /** Format: int32 */ + pageNumber: number; + /** Format: int32 */ + pageSize: number; + /** Format: int32 */ + totalPages: number; + /** Format: int64 */ + undatedCount: number; + }; + MatchOffset: { + /** Format: int32 */ + start: number; + /** Format: int32 */ + length: number; + }; + NlQueryInterpretation: { + resolvedPersons: components["schemas"]["PersonHint"][]; + ambiguousPersons: components["schemas"]["PersonHint"][]; + /** Format: date */ + dateFrom?: string; + /** Format: date */ + dateTo?: string; + keywords: string[]; + rawQuery: string; + keywordsApplied: boolean; + }; + NlSearchResponse: { + result: components["schemas"]["DocumentSearchResult"]; + interpretation: components["schemas"]["NlQueryInterpretation"]; + }; + PersonHint: { + /** Format: uuid */ + id: string; + displayName: string; + }; + SearchMatchData: { + transcriptionSnippet?: string; + titleOffsets: components["schemas"]["MatchOffset"][]; + senderMatched: boolean; + matchedReceiverIds: string[]; + matchedTagIds: string[]; + snippetOffsets: components["schemas"]["MatchOffset"][]; + summarySnippet?: string; + summaryOffsets: components["schemas"]["MatchOffset"][]; + }; CreateRelationshipRequest: { /** Format: uuid */ relatedPersonId: string; @@ -2188,11 +2312,6 @@ export interface components { /** Format: int64 */ transcriptionCount: number; }; - ActivityActorDTO: { - initials: string; - color: string; - name?: string; - }; TranscriptionQueueItemDTO: { /** Format: uuid */ id: string; @@ -2235,25 +2354,6 @@ export interface components { /** Format: int64 */ totalStories: number; }; - PersonSummaryDTO: { - title?: string; - /** Format: uuid */ - id?: string; - displayName?: string; - firstName?: string; - lastName?: string; - /** Format: int64 */ - documentCount?: number; - /** Format: int32 */ - birthYear?: number; - /** Format: int32 */ - deathYear?: number; - alias?: string; - notes?: string; - personType?: string; - familyMember?: boolean; - provisional?: boolean; - }; PersonSearchResult: { items: components["schemas"]["PersonSummaryDTO"][]; /** Format: int64 */ @@ -2265,6 +2365,25 @@ export interface components { /** Format: int32 */ totalPages: number; }; + PersonSummaryDTO: { + title?: string; + /** Format: uuid */ + id?: string; + displayName?: string; + firstName?: string; + lastName?: string; + /** Format: int64 */ + documentCount?: number; + notes?: string; + /** Format: int32 */ + birthYear?: number; + /** Format: int32 */ + deathYear?: number; + provisional?: boolean; + alias?: string; + personType?: string; + familyMember?: boolean; + }; InferredRelationshipWithPersonDTO: { person: components["schemas"]["PersonNodeDTO"]; label: string; @@ -2280,7 +2399,7 @@ export interface components { /** Format: int32 */ deathYear?: number; /** Format: int32 */ - generation?: number | null; + generation?: number; familyMember: boolean; }; InferredRelationshipDTO: { @@ -2433,63 +2552,6 @@ export interface components { /** Format: int32 */ totalPages?: number; }; - DocumentListItem: { - /** Format: uuid */ - id: string; - title: string; - originalFilename: string; - thumbnailUrl?: string; - /** Format: date */ - documentDate?: string; - /** @enum {string} */ - metaDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; - /** Format: date */ - metaDateEnd?: string; - sender?: components["schemas"]["Person"]; - receivers: components["schemas"]["Person"][]; - tags: components["schemas"]["Tag"][]; - archiveBox?: string; - archiveFolder?: string; - location?: string; - summary?: string; - /** Format: int32 */ - completionPercentage: number; - contributors: components["schemas"]["ActivityActorDTO"][]; - matchData: components["schemas"]["SearchMatchData"]; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; - }; - DocumentSearchResult: { - items: components["schemas"]["DocumentListItem"][]; - /** Format: int64 */ - totalElements: number; - /** Format: int32 */ - pageNumber: number; - /** Format: int32 */ - pageSize: number; - /** Format: int32 */ - totalPages: number; - /** Format: int64 */ - undatedCount: number; - }; - MatchOffset: { - /** Format: int32 */ - start: number; - /** Format: int32 */ - length: number; - }; - SearchMatchData: { - transcriptionSnippet?: string; - titleOffsets: components["schemas"]["MatchOffset"][]; - senderMatched: boolean; - matchedReceiverIds: string[]; - matchedTagIds: string[]; - snippetOffsets: components["schemas"]["MatchOffset"][]; - summarySnippet?: string; - summaryOffsets: components["schemas"]["MatchOffset"][]; - }; IncompleteDocumentDTO: { /** Format: uuid */ id: string; @@ -2828,6 +2890,26 @@ export interface operations { }; }; }; + deletePerson: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getDocument: { parameters: { query?: never; @@ -3154,6 +3236,32 @@ export interface operations { }; }; }; + search: { + parameters: { + query: { + pageable: components["schemas"]["Pageable"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NlSearchRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["NlSearchResponse"]; + }; + }; + }; + }; getPersons: { parameters: { query?: { @@ -3184,48 +3292,6 @@ export interface operations { }; }; }; - confirmPerson: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["Person"]; - }; - }; - }; - }; - deletePerson: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; createPerson: { parameters: { query?: never; @@ -4117,6 +4183,26 @@ export interface operations { }; }; }; + backfillTitles: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["BackfillResult"]; + }; + }; + }; + }; backfillFileHashes: { parameters: { query?: never; @@ -4163,6 +4249,28 @@ export interface operations { }; }; }; + confirmPerson: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["Person"]; + }; + }; + }; + }; markOneRead: { parameters: { query?: never; @@ -4443,7 +4551,7 @@ export interface operations { }; }; }; - search: { + search_1: { parameters: { query?: { q?: string; @@ -5067,7 +5175,7 @@ export interface operations { }; }; }; - search_1: { + search_2: { parameters: { query?: { q?: string; diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts index 6efec2a7..4bff75e6 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -53,6 +53,8 @@ export type ErrorCode = | 'FORBIDDEN' | 'CSRF_TOKEN_MISSING' | 'TOO_MANY_LOGIN_ATTEMPTS' + | 'SMART_SEARCH_UNAVAILABLE' + | 'SMART_SEARCH_RATE_LIMITED' | 'VALIDATION_ERROR' | 'BATCH_TOO_LARGE' | 'BULK_EDIT_TOO_MANY_IDS' @@ -178,6 +180,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_csrf_token_missing(); case 'TOO_MANY_LOGIN_ATTEMPTS': return m.error_too_many_login_attempts(); + case 'SMART_SEARCH_UNAVAILABLE': + return m.error_smart_search_unavailable(); + case 'SMART_SEARCH_RATE_LIMITED': + return m.error_smart_search_rate_limited(); case 'VALIDATION_ERROR': return m.error_validation_error(); case 'BATCH_TOO_LARGE':