Compare commits
55 Commits
1b9fb5a359
...
3d929c55c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d929c55c3 | ||
|
|
acc73fd3e1 | ||
|
|
75c9dc4be9 | ||
|
|
5301b52e0f | ||
|
|
62eadcacd6 | ||
|
|
3b54175945 | ||
|
|
894e92327e | ||
|
|
f226f31fee | ||
|
|
a8a5130b02 | ||
|
|
c139f2969e | ||
|
|
1dd40721fe | ||
|
|
2bb1433822 | ||
|
|
b790a6f823 | ||
|
|
35642ce6c4 | ||
|
|
63926f440f | ||
|
|
e485626471 | ||
|
|
345b3eca87 | ||
|
|
53fdc8e3f9 | ||
|
|
884c1156bd | ||
|
|
960f1c171a | ||
|
|
644b7c2683 | ||
|
|
3d2b907fb4 | ||
|
|
cc7132d11d | ||
|
|
34ff3dbdfd | ||
|
|
ab10daf325 | ||
|
|
4a23dfff8e | ||
|
|
08c11e567c | ||
|
|
503ed6adef | ||
|
|
28201e363a | ||
|
|
9f78f25b0a | ||
|
|
1fcadfcd8f | ||
|
|
381bd1d943 | ||
|
|
c8543726ec | ||
|
|
4cbe1dc2d3 | ||
|
|
324a76d6d2 | ||
|
|
f4e8632e0d | ||
|
|
829194f345 | ||
|
|
98ee6cf587 | ||
|
|
778382cd61 | ||
|
|
8d4f30019b | ||
|
|
d65879d273 | ||
|
|
bda7855cad | ||
|
|
03d7d44e57 | ||
|
|
aa200bf3c5 | ||
|
|
7404284130 | ||
|
|
3ddb2b278b | ||
|
|
702a72d575 | ||
|
|
3f74deda8c | ||
|
|
8ed2a6d95b | ||
|
|
deea34c797 | ||
|
|
482a1c2863 | ||
|
|
8e63867ad8 | ||
|
|
6b0a06e8b1 | ||
|
|
7c1eef710c | ||
|
|
03e22a2f26 |
@@ -92,7 +92,6 @@ 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
|
||||
@@ -161,7 +160,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); `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).
|
||||
**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).
|
||||
|
||||
### Security / Permissions
|
||||
|
||||
@@ -269,7 +268,7 @@ Back button pattern — use the shared `<BackButton>` 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); `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).
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -135,12 +135,6 @@ 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,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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<PersonHint> resolvedPersons,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<PersonHint> ambiguousPersons,
|
||||
LocalDate dateFrom,
|
||||
LocalDate dateTo,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<String> keywords,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<TagHint> resolvedTags,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
String rawQuery,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
boolean keywordsApplied,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
boolean tagsApplied
|
||||
) {
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
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.NameMatches;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||
import org.raddatz.familienarchiv.tag.TagService;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@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 MIN_TAG_TERM = 3;
|
||||
private static final int MAX_RESOLVED_TAGS = 10;
|
||||
|
||||
private final OllamaClient ollamaClient;
|
||||
private final PersonService personService;
|
||||
private final DocumentService documentService;
|
||||
private final TagService tagService;
|
||||
|
||||
public NlSearchResponse search(String query, Pageable pageable) {
|
||||
if (query == null || query.length() < MIN_QUERY || query.length() > MAX_QUERY) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Query must be between " + MIN_QUERY + " and " + MAX_QUERY + " characters");
|
||||
}
|
||||
|
||||
OllamaExtraction ext = ollamaClient.parse(query);
|
||||
|
||||
List<String> personNames = ext.personNames() != null ? ext.personNames() : List.of();
|
||||
List<String> keywords = ext.keywords() != null ? ext.keywords() : List.of();
|
||||
|
||||
TagResolution tagResolution = resolveTags(keywords);
|
||||
List<TagHint> resolvedTagHints = tagResolution.hints();
|
||||
List<String> resolvedTagNames = tagResolution.tagNames();
|
||||
List<String> remainingKeywords = tagResolution.remaining();
|
||||
|
||||
NameResolution resolution = resolveNames(personNames);
|
||||
|
||||
if (!resolution.ambiguous().isEmpty()) {
|
||||
NlQueryInterpretation interpretation = new NlQueryInterpretation(
|
||||
List.of(), resolution.ambiguous(),
|
||||
ext.dateFrom(), ext.dateTo(),
|
||||
keywords, List.of(), ext.rawQuery(), false, false);
|
||||
return new NlSearchResponse(DocumentSearchResult.of(List.of()), interpretation);
|
||||
}
|
||||
|
||||
List<PersonHint> resolved = resolution.resolved();
|
||||
List<String> noMatchFragments = resolution.noMatchFragments();
|
||||
List<String> extraFragments = resolution.extraFragments();
|
||||
|
||||
boolean hadStructuredMatch = !resolvedTagHints.isEmpty() || !resolved.isEmpty();
|
||||
String text = buildText(remainingKeywords, noMatchFragments, extraFragments, ext.rawQuery(), hadStructuredMatch);
|
||||
|
||||
if (resolved.size() == 1 && isAnyRole(ext.personRole())) {
|
||||
UUID personId = resolved.get(0).id();
|
||||
DocumentSearchResult docs = documentService.searchDocumentsByPersonId(
|
||||
personId, ext.dateFrom(), ext.dateTo(), pageable);
|
||||
NlQueryInterpretation interpretation = new NlQueryInterpretation(
|
||||
resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, resolvedTagHints, ext.rawQuery(), false, false);
|
||||
return new NlSearchResponse(docs, interpretation);
|
||||
}
|
||||
|
||||
UUID sender = buildSender(resolved, ext.personRole());
|
||||
UUID receiver = buildReceiver(resolved, ext.personRole());
|
||||
|
||||
boolean tagsApplied = !resolvedTagHints.isEmpty();
|
||||
TagOperator tagOperator = tagsApplied ? TagOperator.OR : TagOperator.AND;
|
||||
|
||||
SearchFilters filters = new SearchFilters(
|
||||
text.isBlank() ? null : text,
|
||||
ext.dateFrom(), ext.dateTo(),
|
||||
sender, receiver,
|
||||
resolvedTagNames, null,
|
||||
null, tagOperator, false);
|
||||
|
||||
DocumentSearchResult docs = documentService.searchDocuments(filters, DocumentSort.DATE, "desc", pageable);
|
||||
boolean keywordsApplied = !text.isBlank();
|
||||
NlQueryInterpretation interpretation = new NlQueryInterpretation(
|
||||
resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, resolvedTagHints, ext.rawQuery(), keywordsApplied, tagsApplied);
|
||||
return new NlSearchResponse(docs, interpretation);
|
||||
}
|
||||
|
||||
private NameResolution resolveNames(List<String> personNames) {
|
||||
List<PersonHint> resolved = new ArrayList<>();
|
||||
List<PersonHint> ambiguous = new ArrayList<>();
|
||||
List<String> noMatchFragments = new ArrayList<>();
|
||||
List<String> 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;
|
||||
}
|
||||
NameMatches matches = personService.resolveByName(name);
|
||||
List<Person> direct = matches.direct();
|
||||
List<Person> partial = matches.partial();
|
||||
|
||||
if (direct.size() == 1) {
|
||||
Person p = direct.get(0);
|
||||
resolvedIndex++;
|
||||
if (resolvedIndex <= 2) {
|
||||
resolved.add(new PersonHint(p.getId(), p.getDisplayName()));
|
||||
} else {
|
||||
extraFragments.add(name);
|
||||
}
|
||||
} else if (direct.size() >= 2) {
|
||||
direct.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName())));
|
||||
} else if (!partial.isEmpty()) {
|
||||
partial.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName())));
|
||||
} else {
|
||||
noMatchFragments.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return new NameResolution(resolved, ambiguous, noMatchFragments, extraFragments);
|
||||
}
|
||||
|
||||
private TagResolution resolveTags(List<String> keywords) {
|
||||
LinkedHashSet<Tag> seen = new LinkedHashSet<>();
|
||||
List<String> remaining = new ArrayList<>();
|
||||
|
||||
for (String kw : keywords) {
|
||||
if (kw == null || kw.length() < MIN_TAG_TERM) {
|
||||
remaining.add(kw);
|
||||
continue;
|
||||
}
|
||||
List<Tag> matches = tagService.findByNameContaining(kw);
|
||||
if (matches.isEmpty()) {
|
||||
remaining.add(kw);
|
||||
} else {
|
||||
seen.addAll(matches);
|
||||
}
|
||||
}
|
||||
|
||||
if (seen.size() > MAX_RESOLVED_TAGS) {
|
||||
log.debug("Keyword matched {} tags; capping at {}", seen.size(), MAX_RESOLVED_TAGS);
|
||||
}
|
||||
List<Tag> capped = seen.size() > MAX_RESOLVED_TAGS
|
||||
? new ArrayList<>(seen).subList(0, MAX_RESOLVED_TAGS)
|
||||
: new ArrayList<>(seen);
|
||||
|
||||
// safe: entities are detached here; mutation is for DTO projection only, no dirty-check fires
|
||||
tagService.resolveEffectiveColors(capped);
|
||||
|
||||
List<TagHint> hints = capped.stream()
|
||||
.map(t -> new TagHint(t.getId(), t.getName(), t.getColor()))
|
||||
.toList();
|
||||
List<String> tagNames = capped.stream().map(Tag::getName).toList();
|
||||
|
||||
return new TagResolution(hints, tagNames, remaining);
|
||||
}
|
||||
|
||||
private String buildText(List<String> keywords, List<String> noMatchFragments,
|
||||
List<String> extraFragments, String rawQuery, boolean hadStructuredMatch) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
parts.addAll(keywords);
|
||||
parts.addAll(noMatchFragments);
|
||||
parts.addAll(extraFragments);
|
||||
String text = String.join(" ", parts).strip();
|
||||
if (text.isBlank() && !hadStructuredMatch && 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<PersonHint> 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<PersonHint> 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<PersonHint> resolved,
|
||||
List<PersonHint> ambiguous,
|
||||
List<String> noMatchFragments,
|
||||
List<String> extraFragments
|
||||
) {}
|
||||
|
||||
private record TagResolution(
|
||||
List<TagHint> hints,
|
||||
List<String> tagNames,
|
||||
List<String> remaining
|
||||
) {}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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<String, Bucket> 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();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
) {
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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
|
||||
) {
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
public interface OllamaClient {
|
||||
OllamaExtraction parse(String query);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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<String> personNames,
|
||||
String personRole,
|
||||
LocalDate dateFrom,
|
||||
LocalDate dateTo,
|
||||
List<String> keywords,
|
||||
String rawQuery
|
||||
) {
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
public interface OllamaHealthClient {
|
||||
boolean isHealthy();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
) {
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
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<String> 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<String, Object> 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<String> names = raw.personNames() == null ? List.of() : raw.personNames().stream()
|
||||
.filter(n -> n != null && n.length() <= MAX_NAME_LENGTH)
|
||||
.toList();
|
||||
List<String> 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<String> personNames,
|
||||
@JsonProperty("personRole") String personRole,
|
||||
@JsonProperty("dateFrom") String dateFrom,
|
||||
@JsonProperty("dateTo") String dateTo,
|
||||
@JsonProperty("keywords") List<String> keywords
|
||||
) {
|
||||
}
|
||||
|
||||
private record OllamaGenerateRequest(
|
||||
String model,
|
||||
String prompt,
|
||||
Object format,
|
||||
boolean stream
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record TagHint(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
String name,
|
||||
String color
|
||||
) {
|
||||
}
|
||||
@@ -12,6 +12,3 @@ springdoc:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
|
||||
app:
|
||||
ollama:
|
||||
base-url: http://localhost:11434
|
||||
|
||||
@@ -130,18 +130,6 @@ 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
|
||||
# CPU inference: ~18s warm. Higher ceiling absorbs the cold model load on the
|
||||
# first query after an Ollama (re)start before OLLAMA_KEEP_ALIVE pins it.
|
||||
timeout-seconds: 60
|
||||
health-check-timeout-seconds: 2
|
||||
|
||||
nl-search:
|
||||
rate-limit:
|
||||
max-requests-per-minute: 5
|
||||
|
||||
ocr:
|
||||
sender-model:
|
||||
activation-threshold: 100
|
||||
|
||||
@@ -1,739 +0,0 @@
|
||||
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.NameMatches;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||
import org.raddatz.familienarchiv.tag.TagService;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
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;
|
||||
@Mock TagService tagService;
|
||||
|
||||
NlQueryParserService service;
|
||||
|
||||
static final Pageable PAGE = PageRequest.of(0, 20);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
service = new NlQueryParserService(ollamaClient, personService, documentService, tagService);
|
||||
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
when(documentService.searchDocumentsByPersonId(any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
when(tagService.findByNameContaining(anyString())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
// --- Factory helpers ---
|
||||
|
||||
private OllamaExtraction extraction(List<String> names, String role, LocalDate from, LocalDate to,
|
||||
List<String> 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 NameMatches makeNameMatches() {
|
||||
return new NameMatches(List.of(), List.of());
|
||||
}
|
||||
|
||||
private NameMatches makeNameMatches(List<Person> direct) {
|
||||
return new NameMatches(direct, List.of());
|
||||
}
|
||||
|
||||
private NameMatches makeNameMatches(List<Person> direct, List<Person> partial) {
|
||||
return new NameMatches(direct, partial);
|
||||
}
|
||||
|
||||
private static final UUID P1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
private static final UUID P2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||
private static final UUID P3 = UUID.fromString("00000000-0000-0000-0000-000000000003");
|
||||
|
||||
// --- 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.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
|
||||
NlSearchResponse resp = service.search("Was hat Walter geschrieben?", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> 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.resolveByName("Walter")).thenReturn(makeNameMatches(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.resolveByName("Emma")).thenReturn(makeNameMatches(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.resolveByName("Karl")).thenReturn(makeNameMatches());
|
||||
|
||||
service.search("Briefe von Karl", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> 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.resolveByName("Walter")).thenReturn(makeNameMatches(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.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> 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.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma1, emma2)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE);
|
||||
|
||||
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.resolveByName("Karl")).thenReturn(makeNameMatches());
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||
|
||||
service.search("Briefe von Karl an Emma", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> 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.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||
when(personService.resolveByName("Heinrich")).thenReturn(makeNameMatches(List.of(heinrich)));
|
||||
|
||||
service.search("Briefe von Walter an Emma über Heinrich", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> 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<SearchFilters> 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<SearchFilters> 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<SearchFilters> 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<SearchFilters> 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.resolveByName("Walter")).thenReturn(makeNameMatches(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()).resolveByName(anyString());
|
||||
}
|
||||
|
||||
// --- 20. Cap lives in resolveByName (after classification): a pre-capped 10-direct result
|
||||
// maps straight to ambiguousPersons; the search layer adds no second cap. ---
|
||||
|
||||
@Test
|
||||
void search_tenDirectMatches_allShownAsAmbiguous() {
|
||||
List<Person> ten = new ArrayList<>();
|
||||
for (int i = 0; i < 10; i++) {
|
||||
ten.add(person(UUID.randomUUID(), "Walter", "Person" + i));
|
||||
}
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(ten));
|
||||
|
||||
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<SearchFilters> 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.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||
|
||||
service.search("Briefe an Emma", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> 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();
|
||||
}
|
||||
|
||||
// --- 23a. Partial-only, one candidate → ambiguous (1-item picker), search skipped ---
|
||||
|
||||
@Test
|
||||
void search_partialOnly_oneCandidate_populatesAmbiguous() {
|
||||
Person cramer = person(P1, "Clara", "Cramer");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Clara Cram")).thenReturn(makeNameMatches(List.of(), List.of(cramer)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Clara Cram", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().ambiguousPersons()).hasSize(1);
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// --- 23b. Partial-only, two candidates → ambiguous (multi-item picker) ---
|
||||
|
||||
@Test
|
||||
void search_partialOnly_twoCandidates_populatesAmbiguous() {
|
||||
Person cramer = person(P1, "Clara", "Cramer");
|
||||
Person crammond = person(P2, "Clara", "Crammond");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Clara Cram"))
|
||||
.thenReturn(makeNameMatches(List.of(), List.of(cramer, crammond)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Clara Cram", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
|
||||
}
|
||||
|
||||
// --- 23c. Exactly one direct match → search executes, no picker ---
|
||||
|
||||
@Test
|
||||
void search_oneDirect_executesSearch() {
|
||||
Person clara = person(P1, "Clara", "Cram");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Clara Cram")).thenReturn(makeNameMatches(List.of(clara)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Clara Cram", PAGE);
|
||||
|
||||
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
|
||||
assertThat(resp.interpretation().ambiguousPersons()).isEmpty();
|
||||
}
|
||||
|
||||
// --- Tag resolution helpers ---
|
||||
|
||||
private Tag tag(UUID id, String name) {
|
||||
return Tag.builder().id(id).name(name).build();
|
||||
}
|
||||
|
||||
private Tag tag(UUID id, String name, String color) {
|
||||
return Tag.builder().id(id).name(name).color(color).build();
|
||||
}
|
||||
|
||||
private TagHint tagHint(UUID id, String name, String color) {
|
||||
return new TagHint(id, name, color);
|
||||
}
|
||||
|
||||
private static final UUID T1 = UUID.fromString("00000000-0000-0000-0001-000000000001");
|
||||
|
||||
// --- 24. Single keyword resolves to one tag → tag filter applied ---
|
||||
|
||||
@Test
|
||||
void search_singleKeywordResolvesToTag_appliesTagFilter() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe über Hochzeit", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().tags()).containsExactly("Hochzeit");
|
||||
assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR);
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
|
||||
assertThat(resp.interpretation().resolvedTags().get(0).name()).isEqualTo("Hochzeit");
|
||||
assertThat(resp.interpretation().tagsApplied()).isTrue();
|
||||
assertThat(cap.getValue().text()).isNull();
|
||||
}
|
||||
|
||||
private static final UUID T2 = UUID.fromString("00000000-0000-0000-0001-000000000002");
|
||||
|
||||
// --- 25. Keyword matches multiple tags → all in resolvedTags, OR-union ---
|
||||
|
||||
@Test
|
||||
void search_keywordMatchesMultipleTags_allIncluded() {
|
||||
Tag hochzeit1 = tag(T1, "Hochzeit Raddatz");
|
||||
Tag hochzeit2 = tag(T2, "Hochzeit Braun");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit1, hochzeit2));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe über Hochzeit", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().tags()).containsExactlyInAnyOrder("Hochzeit Raddatz", "Hochzeit Braun");
|
||||
assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR);
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(2);
|
||||
}
|
||||
|
||||
// --- 26. Keyword no tag match → stays as FTS text, resolvedTags empty ---
|
||||
|
||||
@Test
|
||||
void search_keywordNoTagMatch_staysAsFtsText() {
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost")));
|
||||
|
||||
NlSearchResponse resp = service.search("Feldpost Briefe", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).contains("Feldpost");
|
||||
assertThat(cap.getValue().tags()).isEmpty();
|
||||
assertThat(resp.interpretation().resolvedTags()).isEmpty();
|
||||
assertThat(resp.interpretation().tagsApplied()).isFalse();
|
||||
}
|
||||
|
||||
// --- 27. Mixed: one keyword resolves, one doesn't → tag filter + FTS text ---
|
||||
|
||||
@Test
|
||||
void search_mixedKeywords_oneResolves_oneStaysAsText() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit", "Feldpost")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit und Feldpost", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().tags()).containsExactly("Hochzeit");
|
||||
assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR);
|
||||
assertThat(cap.getValue().text()).contains("Feldpost");
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
|
||||
assertThat(resp.interpretation().tagsApplied()).isTrue();
|
||||
}
|
||||
|
||||
// --- 28. personRole=any + 1 person + resolvable keyword → personId search, tagsApplied=false ---
|
||||
|
||||
@Test
|
||||
void search_personRoleAny_singlePerson_resolvableKeyword_tagsAppliedFalse() {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "any", null, null, List.of("Hochzeit")));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter über Hochzeit", PAGE);
|
||||
|
||||
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
assertThat(resp.interpretation().tagsApplied()).isFalse();
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
|
||||
assertThat(resp.interpretation().resolvedTags().get(0).name()).isEqualTo("Hochzeit");
|
||||
}
|
||||
|
||||
// --- 29. Cap: keyword matches > 10 tags → capped at 10 ---
|
||||
|
||||
@Test
|
||||
void search_keywordMatchesMoreThanMaxTags_cappedAtTen() {
|
||||
List<Tag> eleven = new ArrayList<>();
|
||||
for (int i = 0; i < 11; i++) {
|
||||
eleven.add(tag(UUID.randomUUID(), "Thema " + i));
|
||||
}
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Thema")));
|
||||
when(tagService.findByNameContaining("Thema")).thenReturn(eleven);
|
||||
|
||||
NlSearchResponse resp = service.search("Dokumente zum Thema", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(10);
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().tags()).hasSize(10);
|
||||
}
|
||||
|
||||
// --- 30. Short keyword (< 3 chars) → skipped, not passed to TagService ---
|
||||
|
||||
@Test
|
||||
void search_shortKeyword_skippedByTagResolution() {
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("ab", "Krieg")));
|
||||
|
||||
service.search("ab Krieg", PAGE);
|
||||
|
||||
verify(tagService, never()).findByNameContaining("ab");
|
||||
verify(tagService).findByNameContaining("Krieg");
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).contains("ab");
|
||||
}
|
||||
|
||||
// --- 31. Dedup: same tag matched by two keywords → appears once ---
|
||||
|
||||
@Test
|
||||
void search_sameTagMatchedByTwoKeywords_deduplicatedToOne() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit", "hoch")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
when(tagService.findByNameContaining("hoch")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit hoch", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().tags()).hasSize(1);
|
||||
}
|
||||
|
||||
// --- 32. All keywords resolve → rawQuery fallback suppressed, text=null ---
|
||||
|
||||
@Test
|
||||
void search_allKeywordsResolveToTags_rawQueryFallbackSuppressed() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(new OllamaExtraction(List.of(), "any", null, null, List.of("Hochzeit"), "raw query text"));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).isNull();
|
||||
assertThat(cap.getValue().tags()).containsExactly("Hochzeit");
|
||||
}
|
||||
|
||||
// --- 33. Flag independence: keywordsApplied=false AND tagsApplied=true ---
|
||||
|
||||
@Test
|
||||
void search_allKeywordsResolveToTags_keywordsAppliedFalse_tagsAppliedTrue() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit Briefe", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().keywordsApplied()).isFalse();
|
||||
assertThat(resp.interpretation().tagsApplied()).isTrue();
|
||||
}
|
||||
|
||||
// --- 34. Color carried through from resolveEffectiveColors ---
|
||||
|
||||
@Test
|
||||
void search_tagHint_carriesColorSetByResolveEffectiveColors() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
doAnswer(invocation -> {
|
||||
Collection<Tag> tags = invocation.getArgument(0);
|
||||
tags.forEach(t -> t.setColor("sage"));
|
||||
return null;
|
||||
}).when(tagService).resolveEffectiveColors(any());
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().resolvedTags().get(0).color()).isEqualTo("sage");
|
||||
}
|
||||
|
||||
// --- 35. Color stays null when resolveEffectiveColors leaves it unset ---
|
||||
|
||||
@Test
|
||||
void search_tagHint_colorIsNull_whenNoColorResolved() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().resolvedTags().get(0).color()).isNull();
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
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"), List.of(), "Briefe von Walter im Krieg", true, false);
|
||||
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(), List.of(), "Briefe von Walter", false, 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"));
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class NlSearchTagResolutionIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private TagRepository tagRepository;
|
||||
|
||||
@Test
|
||||
void findDescendantIdsByName_parentName_includesChildId() {
|
||||
Tag parent = tagRepository.save(Tag.builder().name("Krieg").build());
|
||||
Tag child = tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build());
|
||||
|
||||
List<UUID> ids = tagRepository.findDescendantIdsByName("Krieg");
|
||||
|
||||
assertThat(ids).containsExactlyInAnyOrder(parent.getId(), child.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDescendantIdsByName_childName_returnsOnlyChild() {
|
||||
Tag parent = tagRepository.save(Tag.builder().name("Krieg").build());
|
||||
Tag child = tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build());
|
||||
|
||||
List<UUID> ids = tagRepository.findDescendantIdsByName("Weltkrieg");
|
||||
|
||||
assertThat(ids).containsExactly(child.getId());
|
||||
assertThat(ids).doesNotContain(parent.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByNameContainingIgnoreCase_parentSubstring_matchesParentOnly() {
|
||||
Tag parent = tagRepository.save(Tag.builder().name("Krieg").build());
|
||||
tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build());
|
||||
|
||||
List<Tag> found = tagRepository.findByNameContainingIgnoreCase("Krieg");
|
||||
|
||||
assertThat(found).extracting(Tag::getName).containsExactlyInAnyOrder("Krieg", "Weltkrieg");
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,6 @@ volumes:
|
||||
minio-data:
|
||||
ocr-models:
|
||||
ocr-cache:
|
||||
ollama-models:
|
||||
|
||||
services:
|
||||
db:
|
||||
@@ -201,73 +200,6 @@ services:
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
# --- Ollama: Model init (one-shot pull) ---
|
||||
# Pulls qwen2.5:7b-instruct-q4_K_M (~4.7 GB) into the ollama-models volume on
|
||||
# first start; exits quickly on subsequent starts (model already cached).
|
||||
# The ollama/ollama image's ENTRYPOINT is `ollama` and the image ships WITHOUT
|
||||
# curl, so the entrypoint is overridden to a shell and readiness is probed with
|
||||
# `ollama list` (not curl). The pull is guarded by a `grep` on the cached model
|
||||
# list so a model already on the volume exits clean WITHOUT a registry round-trip
|
||||
# — a host reboot during a registry/network blip can no longer fail init (which
|
||||
# would block the ollama service via service_completed_successfully).
|
||||
# Backend degrades gracefully (503) if Ollama is absent.
|
||||
ollama-model-init:
|
||||
image: ollama/ollama:0.30.6
|
||||
restart: "no"
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- "ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && (ollama list | grep -q 'qwen2.5:7b-instruct-q4_K_M' || ollama pull qwen2.5:7b-instruct-q4_K_M)"
|
||||
networks:
|
||||
- archiv-net
|
||||
volumes:
|
||||
- ollama-models:/root/.ollama
|
||||
mem_limit: 2g
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=512m
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
# --- Ollama: LLM inference server ---
|
||||
# Serves the pre-pulled model for NL search inference. Backend reaches it at
|
||||
# http://ollama:11434 (application.yaml default; no env override required).
|
||||
# Healthcheck uses `ollama list` because the image has no curl.
|
||||
ollama:
|
||||
image: ollama/ollama:0.30.6
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "11434"
|
||||
networks:
|
||||
- archiv-net
|
||||
volumes:
|
||||
- ollama-models:/root/.ollama
|
||||
environment:
|
||||
# Pin the model in memory (no idle unload). Without this, Ollama evicts
|
||||
# the model after ~5 min idle and the next query pays a cold-load penalty
|
||||
# that exceeds the backend read timeout → NL search 503 after idle.
|
||||
OLLAMA_KEEP_ALIVE: "-1"
|
||||
cpus: "${OLLAMA_CPU_LIMIT:-4.0}"
|
||||
mem_limit: "${OLLAMA_MEM_LIMIT:-8g}"
|
||||
memswap_limit: "${OLLAMA_MEM_LIMIT:-8g}"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=512m
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
test: ["CMD", "ollama", "list"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
depends_on:
|
||||
ollama-model-init:
|
||||
condition: service_completed_successfully
|
||||
|
||||
backend:
|
||||
image: familienarchiv/backend:${TAG:-nightly}
|
||||
build:
|
||||
|
||||
@@ -141,75 +141,6 @@ services:
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
# --- Ollama: Model init (one-shot pull) ---
|
||||
# Pulls qwen2.5:7b-instruct-q4_K_M (~4.7 GB) into the ollama_models volume on first start.
|
||||
# On subsequent starts (model already in volume), exits quickly without re-downloading.
|
||||
# Not started in CI — CI uses explicit service selection
|
||||
# (docker-compose.ci.yml: db minio create-buckets)
|
||||
ollama-model-init:
|
||||
image: ollama/ollama:0.30.6
|
||||
restart: "no"
|
||||
networks:
|
||||
- archiv-net
|
||||
volumes:
|
||||
- ollama_models:/root/.ollama
|
||||
mem_limit: 2g
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=512m
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
# The image ENTRYPOINT is `ollama`, so override it to a shell; the image has
|
||||
# no curl, so readiness is probed with `ollama list` instead of a curl loop.
|
||||
# The pull is guarded by a `grep` on the cached model list so an already-cached
|
||||
# model exits clean without a registry round-trip (offline-safe re-up).
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- "ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && (ollama list | grep -q 'qwen2.5:7b-instruct-q4_K_M' || ollama pull qwen2.5:7b-instruct-q4_K_M)"
|
||||
|
||||
# --- Ollama: LLM inference server ---
|
||||
# Serves the pre-pulled model for NL search inference.
|
||||
# Not started in CI — CI uses explicit service selection
|
||||
# (docker-compose.ci.yml: db minio create-buckets)
|
||||
ollama:
|
||||
image: ollama/ollama:0.30.6
|
||||
container_name: archive-ollama
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "11434"
|
||||
networks:
|
||||
- archiv-net
|
||||
volumes:
|
||||
- ollama_models:/root/.ollama
|
||||
environment:
|
||||
OLLAMA_API_KEY: "${OLLAMA_API_KEY}"
|
||||
# Pin the model in memory (no idle unload) so queries never pay a cold-load
|
||||
# penalty that exceeds the backend read timeout → NL search 503 after idle.
|
||||
OLLAMA_KEEP_ALIVE: "-1"
|
||||
cpus: "${OLLAMA_CPU_LIMIT:-4.0}"
|
||||
mem_limit: "${OLLAMA_MEM_LIMIT:-8g}"
|
||||
memswap_limit: "${OLLAMA_MEM_LIMIT:-8g}"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=512m
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
# `ollama list` hits the local API and exits non-zero if the server is
|
||||
# down — used instead of curl, which the image does not ship.
|
||||
test: ["CMD", "ollama", "list"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s # model weights are pre-loaded by ollama-model-init; service only needs to bind port
|
||||
depends_on:
|
||||
ollama-model-init:
|
||||
condition: service_completed_successfully
|
||||
|
||||
# --- Backend: Spring Boot ---
|
||||
backend:
|
||||
build:
|
||||
@@ -253,8 +184,6 @@ services:
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
|
||||
APP_OCR_BASE_URL: http://ocr-service:8000
|
||||
APP_OCR_TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
|
||||
APP_OLLAMA_BASE_URL: "${APP_OLLAMA_BASE_URL:-http://ollama:11434}"
|
||||
APP_OLLAMA_API_KEY: "${OLLAMA_API_KEY}"
|
||||
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-1.0}
|
||||
# Observability: send traces to Tempo inside archiv-net (OTLP gRPC port 4317)
|
||||
@@ -318,4 +247,3 @@ volumes:
|
||||
frontend_node_modules:
|
||||
ocr_models:
|
||||
ocr_cache:
|
||||
ollama_models:
|
||||
|
||||
@@ -50,17 +50,15 @@ graph TD
|
||||
|
||||
The OCR service requires significant RAM for model loading. The dev compose sets `mem_limit: 12g`.
|
||||
|
||||
| Production target | RAM | Recommended OCR limit | NL Search | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Current server (Hetzner Serverbörse, i7-6700) | 64 GB | 12 GB | Supported | Default `mem_limit: 12g` works comfortably; plenty of headroom for Ollama |
|
||||
| ≥ 16 GB RAM | 16+ GB | 12 GB | Supported | Default works |
|
||||
| 8 GB RAM | 8 GB | 6 GB | Disabled — set `APP_OLLAMA_BASE_URL=` (empty) | Set `OCR_MEM_LIMIT=6g`; accept reduced batch sizes |
|
||||
| 4 GB RAM | 4 GB | — | Unsupported | Disable OCR service (`profiles: [ocr]`); run OCR on demand only |
|
||||
| Production target | RAM | Recommended OCR limit | Notes |
|
||||
|---|---|---|---|
|
||||
| Current server (Hetzner Serverbörse, i7-6700) | 64 GB | 12 GB | Default `mem_limit: 12g` works comfortably |
|
||||
| ≥ 16 GB RAM | 16+ GB | 12 GB | Default works |
|
||||
| 8 GB RAM | 8 GB | 6 GB | Set `OCR_MEM_LIMIT=6g`; accept reduced batch sizes |
|
||||
| 4 GB RAM | 4 GB | — | Disable OCR service (`profiles: [ocr]`); run OCR on demand only |
|
||||
|
||||
On servers with less than 16 GB RAM the default `mem_limit: 12g` cannot be honoured — set the `OCR_MEM_LIMIT` env var (in `.env.production` / `.env.staging`, or as a Gitea secret consumed by the workflow). The prod compose interpolates this var with a 12g default.
|
||||
|
||||
> **Memory budget:** OCR (~6 GB active) + Ollama (~8 GB) = ~14 GB. On servers with less than 16 GB RAM, do not run `docker-compose.observability.yml` continuously alongside both OCR and Ollama.
|
||||
|
||||
### Dev vs production differences
|
||||
|
||||
| Concern | Dev (`docker-compose.yml`) | Prod (`docker-compose.prod.yml`) |
|
||||
@@ -147,16 +145,6 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
||||
| `XDG_CACHE_HOME` | XDG cache base dir — redirects Matplotlib and other XDG-aware libraries away from the read-only `HOME` (`/home/ocr`) to the writable cache volume | `/app/cache` | — | — |
|
||||
| `TORCH_HOME` | PyTorch model cache — redirects `~/.cache/torch` to the writable models volume | `/app/models/torch` | — | — |
|
||||
|
||||
### Ollama (NL search) service
|
||||
|
||||
| Variable | Purpose | Default | Required? | Sensitive? |
|
||||
|---|---|---|---|---|
|
||||
| `APP_OLLAMA_BASE_URL` | Base URL for the Ollama service. Leave empty to disable NL search. | `http://ollama:11434` | — | — |
|
||||
| `APP_OLLAMA_API_KEY` | API key passed as `Authorization: Bearer` to Ollama. Leave empty for unauthenticated access. Note: `OLLAMA_API_KEY` is not enforced in Ollama 0.6.5 or 0.30.6 (see ADR-028). | — | — | YES |
|
||||
| `OLLAMA_CPU_LIMIT` | Docker CPU quota for the Ollama container. On CX42 (8 vCPUs) can be raised to `7.5`. | `4.0` | — | — |
|
||||
| `OLLAMA_MEM_LIMIT` | Memory limit for the Ollama container. Requires CX42 (16 GB RAM). | `8g` | — | — |
|
||||
| `OLLAMA_API_KEY` | API key set on the Ollama service itself. Same value as `APP_OLLAMA_API_KEY`. Leave empty for unauthenticated. | — | — | YES |
|
||||
|
||||
### Observability stack (`docker-compose.observability.yml`)
|
||||
|
||||
| Variable | Purpose | Default | Required? | Sensitive? |
|
||||
@@ -277,18 +265,6 @@ git.raddatz.cloud A <server IP>
|
||||
|
||||
### 3.4 First deploy
|
||||
|
||||
> **First start — Ollama model pull:** On first `docker compose up -d`, the `ollama-model-init` container pulls `qwen2.5:7b-instruct-q4_K_M` (~4.7 GB). At 10 Mbps this takes approximately 60–90 minutes; at 100 Mbps approximately 6–10 minutes. The pull is a one-time operation — subsequent restarts skip it (model already on the `ollama_models` volume). Monitor progress with `docker logs -f $(docker ps -q --filter name=ollama-model-init)`.
|
||||
>
|
||||
> **Do not use `--wait` on first deploy** — `docker compose up -d --wait` waits for all services to reach their health/completion target, including `ollama-model-init`. On first pull this blocks for 60–90 minutes and will time out any CI/deploy script that uses `--wait`.
|
||||
>
|
||||
> **Re-deploy idempotency:** on subsequent `docker compose up -d` runs (including `--force-recreate`), `ollama-model-init` re-executes but exits in seconds — Ollama's CLI skips the download when the model digest already matches what is on the volume.
|
||||
>
|
||||
> **Verify NL search is active** after enabling Ollama (`APP_OLLAMA_BASE_URL=http://ollama:11434`):
|
||||
> ```bash
|
||||
> curl -s http://localhost:8080/api/nl-search?q=brief+von+grossmutter
|
||||
> # Returns 200 with results → NL search is active
|
||||
> # Returns 503 NL_SEARCH_UNAVAILABLE → Ollama is not reachable or APP_OLLAMA_BASE_URL is unset
|
||||
> ```
|
||||
|
||||
```bash
|
||||
# 1. Trigger nightly.yml manually (Repo → Actions → nightly → "Run workflow")
|
||||
@@ -585,55 +561,6 @@ 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` | `60` | Read timeout for inference calls (absorbs cold model load on the first query after an Ollama restart) |
|
||||
| `app.nl-search.rate-limit.max-requests-per-minute` | `5` | Per-user rate limit |
|
||||
|
||||
### Upgrade the Ollama model
|
||||
|
||||
To switch to a newer model version (e.g. a future release of `qwen2.5`):
|
||||
|
||||
1. Update the model name in the `ollama-model-init` `command:` in `docker-compose.yml`.
|
||||
2. Remove the existing model volume to free the old weights:
|
||||
```bash
|
||||
docker volume rm familienarchiv_ollama_models
|
||||
```
|
||||
(In production the volume name is prefixed with the compose project: `archiv-production_ollama-models`.)
|
||||
3. Restart the stack:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
The `ollama-model-init` container pulls the new model weights on first start (~4–8 GB download depending on the model). The `ollama` inference server will not start until the pull completes (`condition: service_completed_successfully`).
|
||||
|
||||
> **`ollama_models` volume:** holds model weights only — fully reproducible by re-pull, no backup needed.
|
||||
|
||||
### Trigger a canonical import
|
||||
|
||||
The importer no longer parses the raw spreadsheet. It consumes the **canonical artifacts**
|
||||
|
||||
@@ -165,23 +165,7 @@ _See also [Chronik](#chronik-internal)._
|
||||
|
||||
**Domain** — a Tier-1 bounded context with its own entities, controller, service, repository, and DTOs. Backend domains: `document`, `person`, `tag`, `user`, `geschichte`, `notification`, `ocr`, `audit`, `dashboard`. Frontend domains mirror this structure under `src/lib/`.
|
||||
|
||||
---
|
||||
|
||||
## 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), `keywordsApplied` (whether keyword FTS was used), `resolvedTags` (tags matched by keyword→tag resolution), and `tagsApplied` (whether the OR-union tag filter was applied).
|
||||
|
||||
**keyword→tag resolution** — the post-Ollama step in `NlQueryParserService` where each LLM-extracted keyword is substring-matched against the tag taxonomy via `TagService.findByNameContaining()`. Keywords that hit one or more tags are removed from the FTS text list and become an OR-union tag filter; keywords with no match remain as FTS text. Matching is case-insensitive and traverses the tag hierarchy via the recursive CTE `findDescendantIdsByName`. See ADR-033.
|
||||
|
||||
**PersonHint** — a lightweight `{id, displayName}` pair used in `NlQueryInterpretation` to describe a resolved or ambiguous person without exposing the full `Person` entity to the frontend.
|
||||
|
||||
**NameMatches** — the Person-domain result of `PersonService.resolveByName(name)`: candidate persons split by name-match strength into `direct` and `partial`. A match is **direct** when every query token is a whole-token match (order-independent, alias/maiden-name aware) across all of a person's name components (`firstName`, `lastName`, `alias`, each `PersonNameAlias` first+last, `title`); a **partial** matched the substring fetch but is not direct (e.g. "Cram" → "Clara Cramer"). The vocabulary is deliberately match strength, not the search layer's resolved/ambiguous buckets — `NlQueryParserService` maps one direct → resolved (auto-select), ≥2 direct → ambiguous, partial-only → ambiguous suggestions ("Meintest du …?"), and no candidates → folded into full-text search.
|
||||
|
||||
**TagHint** — a lightweight `{id, name, color?}` triple used in `NlQueryInterpretation.resolvedTags` to describe a tag matched by keyword→tag resolution. `color` is the tag's effective color (one-level inheritance from parent when the tag has no own color), or null if neither tag nor parent has a color.
|
||||
|
||||
**theme chip** `[frontend]` — a removable chip rendered in `InterpretationChipRow` for each entry in `NlQueryInterpretation.resolvedTags` when `tagsApplied` is `true`. Displays "Thema: {tag.name}" (prefix varies by locale). Clicking × removes the tag from the OR-union filter and navigates to `/documents?tag=…&tagOp=OR` with remaining tag and person parameters preserved.
|
||||
**NameMatches** — the Person-domain result of `PersonService.resolveByName(name)`: candidate persons split by name-match strength into `direct` and `partial`. A match is **direct** when every query token is a whole-token match (order-independent, alias/maiden-name aware) across all of a person's name components (`firstName`, `lastName`, `alias`, each `PersonNameAlias` first+last, `title`); a **partial** matched the substring fetch but is not direct (e.g. "Cram" → "Clara Cramer").
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# 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:** In-path Ollama failures surface via `OllamaClient.parse()` — any `IOException`, read timeout, or non-2xx response is caught by `RestClientOllamaClient` and re-thrown as `DomainException(SMART_SEARCH_UNAVAILABLE, HTTP 503)`. `isHealthy()` has no callers inside `search/`; it is reserved for the ops/health-endpoint polling path only (e.g. a future `/api/health/ollama` endpoint). The regular structured search (`GET /api/documents/search`) is unaffected — it never calls Ollama.
|
||||
|
||||
**Expected inference latency:** 2–15 seconds on the current CPU-only hardware. The frontend issue must show a persistent "Suche läuft…" indicator for the full duration (see `aria-live="polite"` requirement in issue #738 frontend notes). The backend timeout is 30 seconds (`app.ollama.timeout-seconds=30`) — chosen as a safe upper bound for Q4_K_M on the i7-6700 with a realistic 500-character query under modest concurrent load.
|
||||
|
||||
**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.
|
||||
|
||||
**Keyword→tag resolution** (issue #743): After Ollama extracts the `keywords` list, `NlQueryParserService` calls `TagService.findByNameContaining()` for each keyword. Keywords that match one or more tags are removed from the FTS text list and added as OR-union tag filters; keywords with no tag match remain as FTS text. Resolved tags are returned to the frontend as `TagHint` objects in `NlQueryInterpretation.resolvedTags` and rendered as removable "Thema: X" chips. The `tagsApplied` flag signals whether the OR-union filter was actually passed to `DocumentService.searchDocuments()` — it is `false` when the `personRole:any` single-person path is taken, because that path has no tag filter slot. See ADR-033 for the tag name resolution and case-collision rules that `TagService.findByNameContaining()` relies on.
|
||||
|
||||
## Decision
|
||||
|
||||
**Introduce a new `search/` domain package** with a local Ollama integration via `RestClientOllamaClient`. The Ollama service runs as a separate Docker container, reachable only on the internal Docker network (`expose: ["11434"]`, not `ports:`). The backend calls Ollama's `/api/generate` endpoint with grammar-constrained JSON output. Name resolution and document search are performed by existing services after the model returns.
|
||||
|
||||
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`.
|
||||
@@ -1,239 +0,0 @@
|
||||
# ADR-028: Ollama Docker Compose service for NL search
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Status:** Accepted
|
||||
**Deciders:** Marcel Raddatz
|
||||
**Relates to:** #737 (infrastructure), #735 (NL search epic)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Issue #735 introduces natural-language document search, requiring a local LLM to generate embeddings and/or run inference at query time. The family archive stores personal family history — data privacy is non-negotiable, so cloud-based inference APIs are excluded. The production target is a Hetzner CX42 (16 GB RAM, 8 vCPUs, CPU-only, ~32 EUR/month).
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
| Option | Reason rejected |
|
||||
|---|---|
|
||||
| **llama.cpp** | No HTTP API out of the box; requires custom wrapper; higher ops burden |
|
||||
| **vLLM** | GPU-first; significant overhead on CPU-only hardware; overkill for this scale |
|
||||
| **Cloud APIs** (OpenAI, Gemini, etc.) | Vendor lock-in; per-token cost at scale; data leaves the server — unacceptable for a private family archive |
|
||||
| **Ollama** | Self-contained Docker image; built-in HTTP REST API; actively maintained; CPU-compatible; zero egress |
|
||||
|
||||
**Decision:** run Ollama as a Docker Compose service alongside the existing stack.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Hardware minimums and CPU-only constraint
|
||||
|
||||
All inference runs on CPU. The target is the Hetzner CX42 (16 GB RAM, 8 vCPUs).
|
||||
|
||||
| Tier | RAM | NL search |
|
||||
|---|---|---|
|
||||
| CX42 | 16 GB | Supported — full stack including Ollama |
|
||||
| CX32 | 8 GB | Disabled — set `APP_OLLAMA_BASE_URL=` (empty) to skip Ollama entirely |
|
||||
| CX22 | 4 GB | Unsupported for NL search |
|
||||
|
||||
### 2. Memory budget on CX42
|
||||
|
||||
| Component | `mem_limit` | Typical active RSS |
|
||||
|---|---|---|
|
||||
| OCR service | 12g (hard ceiling) | ~6 GB |
|
||||
| Ollama | 8g | ~8 GB |
|
||||
| **Total** | | **~14 GB active** |
|
||||
|
||||
`memswap_limit` on the Ollama service is set to `8g` (matching `mem_limit`) to prevent Linux from swapping model weights into swap under OCR memory pressure. Swapping model weights does not crash the container but silently degrades inference latency. This mirrors the pattern already applied to the OCR service.
|
||||
|
||||
**Operational constraint:** do NOT run `docker-compose.observability.yml` continuously alongside both OCR and Ollama on a CX42. The observability stack adds ~2 GB, which leaves no headroom.
|
||||
|
||||
### 3. Graceful-degradation contract
|
||||
|
||||
`app.ollama.base-url` absent OR blank → Ollama bean NOT registered → NL search returns HTTP 503 with `ErrorCode: NL_SEARCH_UNAVAILABLE`.
|
||||
|
||||
This single code path covers all unavailability scenarios: base-url unset, service unreachable, health check failed, and request timeout.
|
||||
|
||||
#### Why not `@ConditionalOnProperty`
|
||||
|
||||
`@ConditionalOnProperty` registers the bean when the property is present but blank (`APP_OLLAMA_BASE_URL=`). This produces a `RestClient` with an empty base URL that fails at runtime with an opaque error rather than a clean 503.
|
||||
|
||||
#### Correct condition expression
|
||||
|
||||
```java
|
||||
@ConditionalOnExpression("!'${app.ollama.base-url:}'.isBlank()")
|
||||
```
|
||||
|
||||
When the property is absent, the placeholder resolves to `''`; `.isBlank()` returns `true`; negation makes the condition `false`; the bean is not registered. Same result for an explicit empty string (`APP_OLLAMA_BASE_URL=`).
|
||||
|
||||
### 4. Backend configuration pattern
|
||||
|
||||
Use a `@ConfigurationProperties` record, not separate `@Value` injections:
|
||||
|
||||
```java
|
||||
@ConfigurationProperties("app.ollama")
|
||||
record OllamaProperties(String baseUrl, String apiKey) {}
|
||||
```
|
||||
|
||||
`OllamaProperties` is registered unconditionally — it is a plain value holder with no side effects.
|
||||
|
||||
`@ConditionalOnExpression` belongs **only** on `RestClientOllamaClient` (the bean that creates a live network client).
|
||||
|
||||
**Deliberate divergence from the OCR pattern:** the OCR service uses `@Value`-with-default because OCR is always-on and `http://ocr-service:8000` is a safe default. Ollama is truly optional — a missing URL means "feature disabled", not "use this default server". There is no safe default Ollama URL.
|
||||
|
||||
### 5. Optional<OllamaClient> injection
|
||||
|
||||
The NL search service uses constructor injection with `Optional<OllamaClient>`:
|
||||
|
||||
```java
|
||||
private final Optional<OllamaClient> ollamaClient;
|
||||
```
|
||||
|
||||
When empty (bean not registered), the service method returns 503 immediately:
|
||||
|
||||
```java
|
||||
var client = ollamaClient.orElseThrow(
|
||||
() -> DomainException.internal(ErrorCode.NL_SEARCH_UNAVAILABLE, "Ollama not configured"));
|
||||
```
|
||||
|
||||
Prefer this over `@Autowired(required = false)` with a null check — the null-check pattern is noisy when the service already uses `@RequiredArgsConstructor`.
|
||||
|
||||
### 6. Empty API key guard
|
||||
|
||||
`RestClientOllamaClient` omits the `Authorization` header entirely when `apiKey` is blank:
|
||||
|
||||
```java
|
||||
if (!apiKey.isBlank()) {
|
||||
request.header("Authorization", "Bearer " + apiKey);
|
||||
}
|
||||
```
|
||||
|
||||
Sending `Authorization: Bearer ` (empty token) has undefined or potentially broken behavior depending on the Ollama version. This mirrors the `trainingToken` guard in `RestClientOcrClient.java:107`.
|
||||
|
||||
### 7. OLLAMA_API_KEY behavior in Ollama 0.6.5 and 0.30.6
|
||||
|
||||
**Empirically verified (2026-06-06) on both `0.6.5` and `0.30.6`:** `OLLAMA_API_KEY` does **not** enforce request authentication in either version.
|
||||
|
||||
Test matrix run against `/api/tags`:
|
||||
|
||||
| Configuration | No auth header | `Authorization: Bearer ` (empty) | `Authorization: Bearer wrongkey` | `Authorization: Bearer correctkey` |
|
||||
|---|---|---|---|---|
|
||||
| `OLLAMA_API_KEY=` (empty) | 200 | 200 | — | — |
|
||||
| `OLLAMA_API_KEY` unset | 200 | — | — | — |
|
||||
| `OLLAMA_API_KEY=testkey99` | 200 | 200 | 200 | 200 |
|
||||
|
||||
**Finding:** The `OLLAMA_API_KEY` environment variable is not listed in Ollama's startup config dump and does not gate any HTTP request in either tested version. All configurations — empty string, fully unset, and a real key — accept all requests without authentication.
|
||||
|
||||
**Practical implication:** `OLLAMA_API_KEY` provides no defense-in-depth in the tested versions. `archiv-net` network isolation is the only effective security control. The env var is retained in the Compose definition and `.env.example` for forward compatibility if Ollama enables enforcement in a future version, but operators must not rely on it for access control.
|
||||
|
||||
**Backend guard still valid:** the `RestClientOllamaClient` code-level guard (omit `Authorization` header when `apiKey.isBlank()`) remains correct behavior regardless — it prevents a malformed `Authorization: Bearer ` header from being sent.
|
||||
|
||||
### 8. read_only: true feasibility
|
||||
|
||||
**Empirically verified (2026-06-06) on both `0.6.5` and `0.30.6`:** `read_only: true` works with Ollama. All three operations — `ollama serve`, `ollama pull qwen2.5:7b-instruct-q4_K_M`, and `ollama list` — succeeded with exit code 0 in both versions.
|
||||
|
||||
Test run:
|
||||
```bash
|
||||
docker run --rm --read-only \
|
||||
-v ollama_models:/root/.ollama \
|
||||
--tmpfs /tmp \
|
||||
--entrypoint sh ollama/ollama:0.30.6 \
|
||||
-c "ollama serve & sleep 5 && ollama pull qwen2.5:7b-instruct-q4_K_M && ollama list"
|
||||
```
|
||||
|
||||
**Note:** the entrypoint must be overridden to `sh` for the test command — the container's default entrypoint is `/bin/ollama` and does not accept `sh` as a subcommand. This is a Docker invocation detail; the Compose service definition uses the image's default entrypoint and `command:` override for the init container, which works correctly.
|
||||
|
||||
**Result:** `read_only: true` and `tmpfs: - /tmp:size=512m` are applied to both `ollama` and `ollama-model-init`. The `ollama_models` volume handles all persistent writes; no other paths require write access during normal operation.
|
||||
|
||||
### 9. Peak RSS of init container during pull
|
||||
|
||||
**Empirically verified (2026-06-06):** Peak RSS during `qwen2.5:7b-instruct-q4_K_M` pull was **~108 MiB**.
|
||||
|
||||
`docker stats` samples during the pull (15-second intervals):
|
||||
|
||||
| Sample | MEM |
|
||||
|---|---|
|
||||
| 1 | 54.89 MiB |
|
||||
| 2 | 66.3 MiB |
|
||||
| 5 | 97.25 MiB |
|
||||
| 9 | **107.8 MiB** (peak) |
|
||||
|
||||
`mem_limit: 2g` is adequate — the model weights stream directly to the named volume; RSS is dominated by the Ollama server process alone (~100 MB), not the model data. No bump to 4 GB needed.
|
||||
|
||||
### 10. Init container pull mechanism
|
||||
|
||||
The `ollama-model-init` container uses a curl-based readiness loop with captured PID:
|
||||
|
||||
```sh
|
||||
ollama serve & SERVE_PID=$!
|
||||
until curl -sf http://localhost:11434/api/tags; do sleep 1; done
|
||||
ollama pull qwen2.5:7b-instruct-q4_K_M
|
||||
kill $SERVE_PID
|
||||
```
|
||||
|
||||
`kill %1` (job-control syntax) is unreliable in non-interactive `sh -c` contexts. Capturing the PID via `SERVE_PID=$!` is reliable.
|
||||
|
||||
The same endpoint (`/api/tags`) is used for both the init container readiness loop and the main service `healthcheck`.
|
||||
|
||||
### 11. start_period: 60s rationale
|
||||
|
||||
The model is pre-pulled by `ollama-model-init` before the main service starts (via `condition: service_completed_successfully`). At main service startup, Ollama only loads model weights from the named volume and binds port 11434.
|
||||
|
||||
60 seconds is appropriate for this cold-start profile. 300 seconds was considered — that would be appropriate if the service pulled the model itself — but overstates actual startup time when the model is already present on the volume.
|
||||
|
||||
### 12. Security threat model
|
||||
|
||||
**Primary control:** `archiv-net` network isolation. Ollama has no externally exposed port (`expose:` only, not `ports:`). The Caddyfile must not route any path to the Ollama service.
|
||||
|
||||
**Note on `OLLAMA_API_KEY`:** Per §7, `OLLAMA_API_KEY` is not enforced in Ollama 0.6.5 or 0.30.6 and provides no authentication barrier against a compromised backend container. `archiv-net` network isolation is the sole effective security control. The env var is retained for forward compatibility only — do not rely on it for access control.
|
||||
|
||||
Both `ollama` and `ollama-model-init` receive the ADR-019 hardening baseline:
|
||||
|
||||
```yaml
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
```
|
||||
|
||||
### 13. CI exclusion strategy
|
||||
|
||||
Docker Compose profiles are not used — they would add developer friction (requiring `--profile ...` for all local dev commands).
|
||||
|
||||
CI uses explicit service selection in `docker-compose.ci.yml`:
|
||||
```bash
|
||||
docker compose -f docker-compose.ci.yml up -d db minio create-buckets
|
||||
```
|
||||
|
||||
Ollama is simply not listed and is never started in CI. A YAML comment on the `ollama` service block documents this:
|
||||
|
||||
```yaml
|
||||
# Not started in CI — CI uses explicit service selection
|
||||
# (docker-compose.ci.yml: db minio create-buckets)
|
||||
```
|
||||
|
||||
### 14. ollama_models volume operational note
|
||||
|
||||
The `ollama_models` named volume holds model weights only — fully reproducible by re-pull. No backup is needed.
|
||||
|
||||
If the volume fills after a model upgrade:
|
||||
```bash
|
||||
docker volume rm ollama_models && docker compose up -d
|
||||
```
|
||||
The init container re-pulls the model on next startup.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- NL search runs entirely on-premises; no data leaves the server and no per-token cloud cost.
|
||||
- Graceful degradation is a first-class concern: smaller or budget-constrained instances can run the app without Ollama with a single env var change.
|
||||
- The init container pattern keeps model pull out of the critical startup path for the main service, giving accurate healthcheck timings.
|
||||
- `@ConditionalOnExpression` with a blank-check is more correct than `@ConditionalOnProperty` for optional features with no safe default URL.
|
||||
|
||||
### Risks and operational implications
|
||||
|
||||
- **Memory pressure:** OCR + Ollama together consume ~14 GB on a 16 GB host. Running the observability stack simultaneously risks OOM kills. Monitor with `docker stats`.
|
||||
- **CPU inference latency:** `qwen2.5:7b-instruct-q4_K_M` is chosen for CPU viability, but inference on 8 vCPUs will be noticeably slower than GPU-accelerated alternatives. This is acceptable for the family archive use case (low concurrency, not real-time).
|
||||
- All three empirical TBD items from the original issue spec were resolved — see §7 (OLLAMA_API_KEY not enforced), §8 (`read_only: true` works), §9 (peak RSS ~108 MiB).
|
||||
- Model upgrades require a `docker volume rm` to free old weights before pulling the replacement. Document this in runbook/DEPLOYMENT.md.
|
||||
@@ -1,125 +0,0 @@
|
||||
# ADR-034: Ollama in production — deployment, keep-alive pinning, and corrected init recipe
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Status:** Accepted
|
||||
**Deciders:** Marcel Raddatz
|
||||
**Relates to:** #758 (bug), #759 (fix), #737 (NL search infrastructure)
|
||||
**Corrects:** ADR-028 §10–§11 (init recipe and readiness probe)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-028 introduced Ollama as a Docker Compose service for NL search and documented
|
||||
its topology, graceful-degradation contract, and memory budget. Two defects survived
|
||||
that work and only surfaced once NL search reached staging (#758):
|
||||
|
||||
1. **Ollama was added only to the dev `docker-compose.yml`.** Staging and production
|
||||
deploy from the self-contained `docker-compose.prod.yml`, which had no `ollama`
|
||||
service. The backend defaults to `app.ollama.base-url: http://ollama:11434`, so its
|
||||
client bean was active and resolved to a non-existent host → `ResourceAccessException`
|
||||
→ HTTP 503 on every NL search.
|
||||
2. **The init recipe documented in ADR-028 §10 never worked.** The `ollama/ollama` image
|
||||
`ENTRYPOINT` is `ollama`, so a bare `command: sh -c "…"` ran as `ollama sh -c "…"`
|
||||
(`unknown command "sh"`), and the image ships **no curl**, so the curl-based readiness
|
||||
loop and the curl healthcheck could never pass.
|
||||
|
||||
This ADR records the production deployment decision and the corrected operational
|
||||
contract. It is also the durable record of *why* `OLLAMA_KEEP_ALIVE=-1` is set, so a
|
||||
future maintainer does not "optimize" it away and reintroduce the cold-load 503.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Ollama is a first-class production service
|
||||
|
||||
`docker-compose.prod.yml` now defines `ollama` + `ollama-model-init` + the
|
||||
`ollama-models` volume, mirroring the dev stack. The graceful-degradation contract from
|
||||
ADR-028 §3 is preserved: `backend` has **no** hard `depends_on` on `ollama`, so an absent
|
||||
or unhealthy Ollama still yields a clean 503 rather than blocking backend startup.
|
||||
|
||||
### 2. Corrected init recipe (supersedes ADR-028 §10)
|
||||
|
||||
The init container overrides the image entrypoint to a shell and probes readiness with
|
||||
`ollama list` (not curl, which the image lacks):
|
||||
|
||||
```sh
|
||||
ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && \
|
||||
(ollama list | grep -q 'qwen2.5:7b-instruct-q4_K_M' || ollama pull qwen2.5:7b-instruct-q4_K_M)
|
||||
```
|
||||
|
||||
```yaml
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
```
|
||||
|
||||
The pull is **guarded by a grep on the cached model list**. A model already on the volume
|
||||
exits clean without any registry round-trip. This makes re-up offline-safe: a host reboot
|
||||
during a registry/network blip can no longer fail init (which, via
|
||||
`condition: service_completed_successfully`, would otherwise block the `ollama` service
|
||||
and take NL search down until the registry was reachable again). The same recipe is used
|
||||
in dev and prod — one mental model.
|
||||
|
||||
### 3. Healthcheck uses `ollama list` (supersedes ADR-028 §11 probe)
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD", "ollama", "list"]
|
||||
```
|
||||
|
||||
`ollama list` hits the local API and exits non-zero when the server is down — the correct
|
||||
probe for a curl-less image. The `start_period: 60s` rationale from ADR-028 §11 still holds.
|
||||
|
||||
### 4. `OLLAMA_KEEP_ALIVE=-1` — pin the model in memory
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
OLLAMA_KEEP_ALIVE: "-1"
|
||||
```
|
||||
|
||||
By default Ollama evicts an idle model after ~5 minutes. The next query then pays a
|
||||
cold-load penalty that exceeds the backend read timeout, producing an NL search 503 after
|
||||
any idle period. Pinning the model (`-1` = never unload) keeps warm-path latency
|
||||
predictable (~18 s on CPU). **Do not remove this** without re-introducing the post-idle
|
||||
cold-load 503.
|
||||
|
||||
### 5. Read timeout raised 30 → 60 s
|
||||
|
||||
`app.ollama.timeout-seconds` is raised from 30 to 60 (`application.yaml`, mirrored in
|
||||
`DEPLOYMENT.md`). Warm CPU inference is ~18 s; the higher ceiling absorbs the one cold
|
||||
model load on the first query after an Ollama (re)start, before §4's pin takes hold.
|
||||
|
||||
**Implicit NFR made explicit:** NL search shall return a result or a 503 within 60 s; the
|
||||
cold-start path immediately after an Ollama restart is the only path that approaches this
|
||||
ceiling.
|
||||
|
||||
### 6. Hard-OOM trade-off (refines ADR-028 §2)
|
||||
|
||||
`memswap_limit == mem_limit` (both `${OLLAMA_MEM_LIMIT:-8g}`) disables swap for the
|
||||
container. Combined with §4's pinned model, a memory-pressure event is a **hard OOM-kill,
|
||||
not graceful latency degradation**. This is deliberate — swap-thrashing an LLM is worse
|
||||
than a clean restart — but it means the 8 GB envelope is a real ceiling. `qwen2.5-7B-q4`
|
||||
plus its KV cache under load sits close enough to 8 GB that this needs a Prometheus
|
||||
memory alert on the `ollama` container before it bites in production (tracked as
|
||||
observability follow-up, not in this PR).
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- NL search works on staging/production, not just dev — the actual deploy artifact now
|
||||
matches the documented architecture.
|
||||
- Re-up is offline-safe: a cached model never depends on registry reachability.
|
||||
- The keep-alive pin and timeout ceiling make NL search latency predictable on CPU.
|
||||
|
||||
### Risks and operational implications
|
||||
|
||||
- **Hard OOM under memory pressure** (§6): a Prometheus alert on `ollama` container memory
|
||||
is required before this is load-bearing in prod. Tracked as an observability follow-up.
|
||||
- **Unauthenticated inference** relies entirely on `archiv-net` isolation (ADR-028 §7/§12,
|
||||
unchanged). Sending an `Authorization` header from `RestClientOllamaClient` is a separate
|
||||
durable hardening item, tracked outside this PR.
|
||||
- ADR-028 §10–§11 describe a recipe that never functioned; this ADR is the authoritative
|
||||
init/healthcheck contract going forward.
|
||||
53
docs/adr/034-remove-nl-search.md
Normal file
53
docs/adr/034-remove-nl-search.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# ADR-034 — Remove NL/smart-search (supersedes ADR-028 ×2, ADR-034-ollama, ADR-035)
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Status:** Accepted
|
||||
**Issue:** #772
|
||||
**Supersedes:** ADR-028 (nl-search-ollama), ADR-028 (ollama-docker-compose-service), ADR-034 (ollama-production-deployment-and-keep-alive), ADR-035 (rule-based-nlp-service)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The natural-language search feature ("KI-Suche" / smart search) allowed users to enter
|
||||
free-form queries like *"Was hat Walter an Emma im Krieg geschrieben?"* and have them
|
||||
interpreted by an LLM into structured filters (persons, tags, date range, keywords).
|
||||
|
||||
The feature went through two major iterations:
|
||||
1. **Ollama integration** (ADR-028): an `ollama` Docker service running a local LLM
|
||||
(llama3.2/gemma3) parsed queries via a JSON-mode prompt.
|
||||
2. **Rule-based NLP service** (ADR-035): after Ollama proved too slow and unreliable on
|
||||
CPU-only hardware, a Python FastAPI microservice (`nlp-service`, port 8001) replaced
|
||||
it with deterministic regex + spaCy parsing plus a lightweight LLM call.
|
||||
|
||||
Both approaches shared the same fundamental problem: inference on the production server
|
||||
(Hetzner Serverbörse, no GPU, 64 GB RAM, i7-6700) was too slow to be useful, with
|
||||
typical query latencies of 10–30 seconds. Users got better and faster results from
|
||||
the existing keyword search with date/person/tag filters.
|
||||
|
||||
## Decision
|
||||
|
||||
**Remove the NL search feature entirely.** The Python `nlp-service` microservice, the
|
||||
Spring Boot `search/` package (`NlSearchController`, `NlQueryParserService`,
|
||||
`RestClientNlpClient`, `NlSearchRateLimiter`, and all supporting classes), the frontend
|
||||
NL search components (`SmartModeToggle`, `SmartSearchStatus`, `InterpretationChipRow`,
|
||||
`DisambiguationPicker`), the related Docker Compose services, Prometheus scrape job,
|
||||
Grafana dashboard, and all i18n keys are removed.
|
||||
|
||||
The existing structured search (FTS keyword + person/tag/date/directional filters) is
|
||||
sufficient for the archive's current audience and search workload.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Capability removed:** users can no longer enter free-form natural-language queries.
|
||||
They must use the structured filter bar (keyword text box + person/tag/date/directional
|
||||
dropdowns). For documents where these filters are sufficient, there is no regression.
|
||||
- **Operational simplification:** the Docker Compose stack loses two services
|
||||
(`nlp-service` and previously `ollama`/`ollama-model-init`). Memory budget on the
|
||||
production host is freed. No external model weights need to be kept warm.
|
||||
- **Future reinstatement:** if a GPU-capable host becomes available, re-implementing
|
||||
server-side LLM inference would be straightforward given the clean separation of the
|
||||
`NlSearchController` entry point. However, this ADR deliberately avoids leaving dead
|
||||
infrastructure or stub code in place — start clean if and when that becomes viable.
|
||||
- **No data or schema change:** only query/endpoint code and Docker services are removed.
|
||||
The `documents`, `persons`, and `tags` tables and their FTS indexes are untouched.
|
||||
@@ -12,15 +12,13 @@ System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
|
||||
Container(frontend, "Web Frontend", "SvelteKit / Node adapter / port 3000", "Server-side rendered UI. Handles auth session cookies, document search and viewer, transcription editor, annotation layer, family tree (Stammbaum), stories (Geschichten), activity feed (Chronik), enrichment workflow, and admin panel.")
|
||||
Container(backend, "API Backend", "Spring Boot 4 / Java 21 / Jetty / port 8080", "REST API. Implements document management, search, user auth, file upload/download, transcription, OCR orchestration, and SSE notifications. Trusts X-Forwarded-* headers from Caddy.")
|
||||
Container(ocr, "OCR Service", "Python FastAPI / port 8000", "Handwritten text recognition (HTR) and OCR microservice. Single-node by design — see ADR-001. Reachable only on the internal Docker network; no external port exposed.")
|
||||
Container(ollama, "Ollama LLM Service", "ollama/ollama:0.30.6 / port 11434 (internal only)", "Local LLM inference server for NL search. Runs qwen2.5:7b-instruct-q4_K_M on CPU. Reachable only on the internal Docker network; no external port exposed. Disabled when APP_OLLAMA_BASE_URL is unset or blank.")
|
||||
' Named volume: ollama_models — model weights, fully reproducible, no backup needed
|
||||
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.")
|
||||
}
|
||||
|
||||
System_Boundary(observability, "Observability Stack (/opt/familienarchiv/docker-compose.observability.yml)") {
|
||||
Container(prometheus, "Prometheus", "prom/prometheus:v3.4.0", "Scrapes metrics from backend (8081 /actuator/prometheus), OCR service (8000 /metrics), Ollama (11434 /metrics), node-exporter, and cAdvisor. Retention: 30 days.")
|
||||
Container(prometheus, "Prometheus", "prom/prometheus:v3.4.0", "Scrapes metrics from backend (8081 /actuator/prometheus), OCR service (8000 /metrics), node-exporter, and cAdvisor. Retention: 30 days.")
|
||||
Container(node_exporter, "Node Exporter", "prom/node-exporter:v1.9.0", "Host-level CPU, memory, disk, and network metrics.")
|
||||
Container(cadvisor, "cAdvisor", "gcr.io/cadvisor/cadvisor:v0.52.1", "Per-container resource metrics.")
|
||||
Container(loki, "Loki", "grafana/loki:3.4.2", "Stores log streams from all containers.")
|
||||
@@ -43,12 +41,11 @@ 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")
|
||||
Rel(prometheus, ocr, "Scrapes OCR + http_* metrics", "HTTP 8000 /metrics")
|
||||
Rel(prometheus, ollama, "Scrapes LLM request metrics", "HTTP 11434 /metrics")
|
||||
|
||||
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
|
||||
Rel(grafana, loki, "Queries logs", "HTTP 3100")
|
||||
Rel(grafana, tempo, "Queries traces", "HTTP 3200")
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
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 keywords to tags via TagService.findByNameContaining(), (4) resolves each person name via PersonService.findByDisplayNameContaining(), (5) applies multi-name / personRole heuristics, (6) delegates to DocumentService.searchDocuments() or searchDocumentsByPersonId(). Returns NlSearchResponse. Never logs raw query content (PII).")
|
||||
Component(ollamaClient, "RestClientOllamaClient", "Spring Service — implements OllamaClient + OllamaHealthClient", "HTTP client for the Ollama API. Uses two separate RestClient instances: inference client (30 s read timeout) and health-check client (2 s connect timeout). Calls POST /api/generate with grammar-constrained JSON schema (personNames, personRole, dateFrom, dateTo, keywords). isHealthy() polls GET /api/tags. Null-coalesces absent personNames/keywords to List.of(). Defaults unknown personRole to 'any' with a warning log. Maps timeout/5xx/parse errors to DomainException(SMART_SEARCH_UNAVAILABLE / HTTP 503).")
|
||||
Component(ollamaProps, "OllamaProperties", "@ConfigurationProperties(\"app.ollama\")", "Config bean: baseUrl, model (qwen2.5:7b-instruct-q4_K_M), timeoutSeconds (default: 30), healthCheckTimeoutSeconds (default: 2).")
|
||||
Component(rateLimitProps, "NlSearchRateLimitProperties", "@ConfigurationProperties(\"app.nl-search.rate-limit\")", "Config bean: maxRequestsPerMinute (default: 5).")
|
||||
}
|
||||
|
||||
Component(personSvc, "PersonService", "Spring Service", "See diagram 3e. findByDisplayNameContaining(fragment) delegates to PersonRepository.searchByName() — covers first+last name, alias, and name aliases via LEFT JOIN.")
|
||||
Component(documentSvc, "DocumentService", "Spring Service", "See diagram 3b. searchDocuments() for keyword/sender/receiver/date queries. searchDocumentsByPersonId() for OR-semantics single-person queries (person as sender OR receiver, no keyword filter).")
|
||||
Component(tagSvc, "TagService", "Spring Service", "See diagram 3b. findByNameContaining(fragment) delegates to TagRepository.findByNameContainingIgnoreCase(). resolveEffectiveColors() applies one-level color inheritance in-place on a collection of Tag entities.")
|
||||
|
||||
Rel(frontend, nlCtrl, "POST /api/search/nl with JSON query", "HTTP / JSON")
|
||||
Rel(nlCtrl, rateLimiter, "checkAndConsume(userEmail)")
|
||||
Rel(nlCtrl, parserSvc, "parse(query)")
|
||||
Rel(parserSvc, ollamaClient, "parse(rawQuery) — extracts intent", "HTTP / JSON")
|
||||
Rel(ollamaClient, ollama, "POST /api/generate (grammar-constrained JSON schema)", "HTTP / REST")
|
||||
Rel(ollamaClient, ollama, "GET /api/tags (health check)", "HTTP / REST")
|
||||
Rel(parserSvc, tagSvc, "findByNameContaining(keyword) — keyword→tag resolution")
|
||||
Rel(parserSvc, tagSvc, "resolveEffectiveColors(tags)")
|
||||
Rel(parserSvc, personSvc, "findByDisplayNameContaining(name) for each extracted name")
|
||||
Rel(parserSvc, documentSvc, "searchDocuments() or searchDocumentsByPersonId()")
|
||||
Rel(documentSvc, db, "JPA queries", "JDBC")
|
||||
Rel(personSvc, db, "JPA queries", "JDBC")
|
||||
Rel(tagSvc, db, "JPA queries", "JDBC")
|
||||
|
||||
@enduml
|
||||
808
docs/specs/lesereisen-editor-spec.html
Normal file
808
docs/specs/lesereisen-editor-spec.html
Normal file
@@ -0,0 +1,808 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Lesereisen — Journey-Editor · Familienarchiv</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
|
||||
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||
.pill-o{background:var(--orange-tint);color:var(--orange-dark);}
|
||||
.section{margin-bottom:64px;}
|
||||
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
|
||||
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
|
||||
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
|
||||
.jh-o{background:var(--orange-tint);border:1px solid #F0C99A;}
|
||||
.jh-o .jn{color:var(--orange);}
|
||||
.jh-o p,.jh-o .fl{color:var(--orange-dark);}
|
||||
.scr{margin-bottom:56px;}
|
||||
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;}
|
||||
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
|
||||
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
|
||||
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.fa-link.active{color:var(--mint);}
|
||||
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
|
||||
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
.m-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0;}
|
||||
.m-logo{font-size:6px;font-weight:900;color:#fff;letter-spacing:.7px;border-bottom:1.5px solid var(--mint);padding-bottom:1px;}
|
||||
.m-nav-r{margin-left:auto;display:flex;gap:4px;align-items:center;}
|
||||
.m-av{width:14px;height:14px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
.m-ham{display:flex;flex-direction:column;gap:2px;width:12px;}
|
||||
.m-ham span{height:1.5px;background:rgba(255,255,255,.6);border-radius:1px;}
|
||||
|
||||
/* ── impl-ref table ── */
|
||||
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||
.at thead tr{border-bottom:1px solid #2A2A26;}
|
||||
.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}
|
||||
.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}
|
||||
.at tr:last-child td{border-bottom:none;}
|
||||
.at td:first-child{color:#7A7A72;}
|
||||
.at td:nth-child(2){color:#E8E8E2;font-weight:500;}
|
||||
.at td:nth-child(3){color:#5A5A55;}
|
||||
.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||
|
||||
/* ── LLM guide ── */
|
||||
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
|
||||
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;}
|
||||
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
|
||||
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||
.llm li{margin-bottom:4px;}
|
||||
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.llm td{color:var(--color-text-muted);}
|
||||
|
||||
/* ── Editor chrome (shared with writer spec) ── */
|
||||
.ed-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 14px;gap:8px;height:38px;flex-shrink:0;}
|
||||
.ed-back{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);flex-shrink:0;}
|
||||
.ed-title-label{font-family:var(--font-sans);font-size:10px;font-weight:500;color:var(--color-text);flex:1;}
|
||||
.ed-status-pill{display:inline-flex;align-items:center;padding:2px 7px;border-radius:20px;font-size:8px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;flex-shrink:0;}
|
||||
.ed-status-draft{background:#F0EFE9;color:#6B6A63;border:1px solid #D8D7D0;}
|
||||
.ed-status-pub{background:var(--green-tint);color:var(--green-dark);border:1px solid #A0D8A8;}
|
||||
.ed-delete-link{font-size:8px;font-weight:600;color:#DC4C3E;margin-left:8px;}
|
||||
.ed-split{display:flex;flex:1;overflow:hidden;}
|
||||
.ed-sidebar{width:210px;flex-shrink:0;border-left:1px solid #e4e2d7;background:#fff;display:flex;flex-direction:column;overflow-y:auto;}
|
||||
.ed-sb-section{padding:12px 12px 10px;}
|
||||
.ed-sb-title{font-size:8px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;}
|
||||
.ed-sb-divider{height:1px;background:#e4e2d7;}
|
||||
.ed-search-row{display:flex;align-items:center;gap:6px;background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:4px 8px;margin-bottom:6px;}
|
||||
.ed-search-input{font-size:9px;color:var(--color-text-muted);}
|
||||
.ed-chip{display:inline-flex;align-items:center;gap:4px;padding:3px 7px;background:var(--sand);border:1px solid var(--color-border);border-radius:12px;font-size:8px;font-weight:500;color:var(--color-text);margin:0 4px 4px 0;}
|
||||
.ed-chip-x{color:var(--color-text-muted);font-size:9px;cursor:pointer;margin-left:2px;}
|
||||
.ed-hint{font-size:8px;color:var(--color-text-muted);line-height:1.5;margin-top:4px;}
|
||||
.ed-savebar{background:#fff;border-top:1px solid #e4e2d7;padding:9px 14px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;gap:10px;}
|
||||
.ed-savebar-hint{font-size:8px;color:var(--color-text-muted);}
|
||||
.ed-savebar-actions{display:flex;align-items:center;gap:7px;}
|
||||
.ed-btn-ghost{font-size:9px;font-weight:600;padding:5px 12px;border-radius:var(--radius-sm);border:1px solid var(--color-border);color:var(--color-text);background:#fff;cursor:pointer;white-space:nowrap;}
|
||||
.ed-btn-ghost.retract{color:#B46820;border-color:#E8D5B0;}
|
||||
.ed-btn-primary{font-size:9px;font-weight:600;padding:5px 12px;border-radius:var(--radius-sm);background:var(--navy);color:#fff;border:none;cursor:pointer;white-space:nowrap;}
|
||||
|
||||
/* ── Journey Editor main area ── */
|
||||
.je-main{flex:1;display:flex;flex-direction:column;padding:14px 16px;overflow-y:auto;gap:8px;background:var(--color-page);}
|
||||
.je-title-input{font-family:var(--font-display);font-size:15px;font-weight:400;color:var(--color-text);border:none;border-bottom:1px solid var(--color-border);padding:4px 0 6px;width:100%;outline:none;background:transparent;letter-spacing:-.01em;}
|
||||
.je-title-input.placeholder{color:var(--color-text-muted);font-style:italic;}
|
||||
.je-sep{height:1px;background:var(--color-border);margin:2px 0;}
|
||||
.je-intro-area{font-family:Georgia,serif;font-size:9px;line-height:1.7;color:var(--color-text-muted);font-style:italic;border:none;padding:5px 0;width:100%;outline:none;background:transparent;min-height:36px;resize:none;}
|
||||
.je-intro-label{font-size:7.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:2px;}
|
||||
.je-list-label{font-size:7.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:5px;margin-top:4px;}
|
||||
|
||||
/* ── Item rows ── */
|
||||
.je-item{display:flex;align-items:stretch;gap:0;background:#fff;border:1px solid #E4E2D7;border-radius:4px;margin-bottom:5px;overflow:hidden;}
|
||||
.je-drag{width:16px;background:#F5F4EE;border-right:1px solid #E4E2D7;display:flex;align-items:center;justify-content:center;cursor:grab;flex-shrink:0;}
|
||||
.je-drag-dots{display:flex;flex-direction:column;gap:2px;}
|
||||
.je-drag-dot{width:3px;height:3px;border-radius:50%;background:#C4C3BC;}
|
||||
.je-num{width:20px;display:flex;align-items:flex-start;justify-content:center;padding-top:8px;font-size:8px;font-weight:700;color:#9B9A93;flex-shrink:0;}
|
||||
.je-body{flex:1;padding:7px 8px 7px 4px;}
|
||||
.je-doc-title{font-size:9px;font-weight:600;color:var(--navy);line-height:1.3;margin-bottom:2px;}
|
||||
.je-doc-meta{font-size:7.5px;color:var(--color-text-muted);margin-bottom:5px;}
|
||||
.je-note-area{width:100%;min-height:32px;font-family:Georgia,serif;font-size:8px;line-height:1.55;color:var(--color-text);font-style:italic;border:1px solid var(--color-border);border-radius:3px;background:var(--color-surface);padding:4px 6px;resize:none;outline:none;}
|
||||
.je-note-add{font-size:7.5px;font-weight:600;color:var(--blue);cursor:pointer;display:inline-flex;align-items:center;gap:2px;}
|
||||
.je-remove{width:24px;display:flex;align-items:flex-start;justify-content:center;padding-top:7px;flex-shrink:0;}
|
||||
.je-remove-x{font-size:11px;color:#C4C3BC;cursor:pointer;line-height:1;font-weight:300;}
|
||||
.je-interlude-bg{background:var(--orange-tint);border-color:#F0C99A;}
|
||||
.je-interlude-icon{font-size:8px;color:var(--orange);margin-bottom:2px;}
|
||||
.je-interlude-area{width:100%;min-height:36px;font-family:Georgia,serif;font-size:8px;line-height:1.6;color:var(--color-text);font-style:italic;border:1px solid #F0C99A;border-radius:3px;background:rgba(255,255,255,.6);padding:4px 6px;resize:none;outline:none;}
|
||||
.je-empty{padding:16px;text-align:center;border:1px dashed var(--color-border);border-radius:4px;background:var(--color-surface);}
|
||||
.je-empty-text{font-family:Georgia,serif;font-size:8px;color:var(--color-text-muted);font-style:italic;}
|
||||
|
||||
/* ── Add bar ── */
|
||||
.je-add-bar{display:flex;gap:7px;padding:6px 0 4px;}
|
||||
.je-add-btn{font-size:8px;font-weight:600;padding:5px 10px;border-radius:3px;border:1px dashed var(--color-border);color:var(--color-text-muted);background:transparent;cursor:pointer;display:flex;align-items:center;gap:3px;}
|
||||
.je-add-btn:hover{border-color:var(--navy);color:var(--navy);}
|
||||
|
||||
/* ── Inline note editing state (highlight) ── */
|
||||
.je-note-editing{border-color:var(--navy);background:#fff;}
|
||||
|
||||
/* ── Mobile journey editor ── */
|
||||
.mob-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 10px;gap:6px;height:34px;flex-shrink:0;}
|
||||
.mob-back{font-size:8px;color:var(--color-text-muted);}
|
||||
.mob-label{font-family:var(--font-sans);font-size:9px;font-weight:500;color:var(--color-text);flex:1;}
|
||||
.mob-body{flex:1;overflow-y:auto;padding:10px 12px;display:flex;flex-direction:column;gap:7px;background:var(--color-page);}
|
||||
.mob-title-input{font-family:var(--font-display);font-size:13px;color:var(--color-text-muted);font-style:italic;border:none;border-bottom:1px solid var(--color-border);padding:3px 0 5px;width:100%;background:transparent;outline:none;}
|
||||
.mob-collapsible{background:#fff;border:1px solid #e4e2d7;border-radius:3px;overflow:hidden;}
|
||||
.mob-coll-hdr{display:flex;align-items:center;justify-content:space-between;padding:7px 9px;font-size:8.5px;font-weight:600;color:var(--color-text);}
|
||||
.mob-coll-chevron{font-size:9px;color:var(--color-text-muted);}
|
||||
.mob-savebar{background:#fff;border-top:1px solid #e4e2d7;padding:8px 10px;display:flex;gap:6px;flex-shrink:0;}
|
||||
.mob-btn{font-size:8.5px;font-weight:600;padding:7px 0;border-radius:3px;text-align:center;flex:1;}
|
||||
.mob-btn-ghost{border:1px solid var(--color-border);color:var(--color-text);background:#fff;}
|
||||
.mob-btn-primary{background:var(--navy);color:#fff;border:none;}
|
||||
.mob-je-item{display:flex;align-items:stretch;gap:0;background:#fff;border:1px solid #E4E2D7;border-radius:3px;margin-bottom:4px;overflow:hidden;}
|
||||
.mob-je-drag{width:14px;background:#F5F4EE;border-right:1px solid #E4E2D7;display:flex;align-items:center;justify-content:center;}
|
||||
.mob-je-body{flex:1;padding:6px 7px;}
|
||||
.mob-je-title{font-size:8.5px;font-weight:600;color:var(--navy);line-height:1.3;margin-bottom:1px;}
|
||||
.mob-je-meta{font-size:7px;color:var(--color-text-muted);}
|
||||
.mob-je-note{margin-top:4px;padding:3px 5px;background:var(--color-surface);border-left:2px solid var(--mint);font-size:7.5px;font-style:italic;color:var(--color-text-muted);}
|
||||
.mob-je-interlude{background:var(--orange-tint);border-color:#F0C99A;}
|
||||
.mob-je-interlude-text{font-size:7.5px;font-style:italic;color:var(--color-text);}
|
||||
|
||||
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<!-- ═══ DOC HEADER ═══ -->
|
||||
<div class="doc-header">
|
||||
<div>
|
||||
<h1>Lesereisen — Journey-Editor</h1>
|
||||
<p>Kuratierungs-Oberfläche für <code>JourneyEditor</code> auf <code>/geschichten/[id]/edit</code> (wenn <code>type === 'JOURNEY'</code>). Geordnete Briefliste mit Drag-to-Reorder, Dokumenten-Picker, Interlude-Notizen und Inline-Annotation-Editing. Ersetzt den TipTap-Editor für den Journey-Typ.</p>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
Familienarchiv<br/>
|
||||
<span class="pill pill-o">Final Spec</span><br/>
|
||||
2026-06-07 · @leonievoss<br/>
|
||||
<span style="font-size:10px;margin-top:4px;display:inline-block;">Issue #753</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ JOURNEY HEADER ═══ -->
|
||||
<div class="jh jh-o">
|
||||
<div class="jn">E</div>
|
||||
<div>
|
||||
<h2>Journey-Editor</h2>
|
||||
<p>BLOG_WRITERs kuratieren eine geordnete Briefsequenz — Briefe hinzufügen, Zwischentexte einfügen, Reihenfolge per Drag anpassen, Notizen inline bearbeiten.</p>
|
||||
<div class="fl">/geschichten/[id]/edit (type === 'JOURNEY')</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ KONZEPT ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Konzept</div>
|
||||
<p class="prose">Der <code>JourneyEditor</code> ist eine parallele Implementierung zum bestehenden <code>GeschichteEditor</code> und wird auf derselben Edit-Route eingeblendet wenn <code>type === 'JOURNEY'</code>. Das Split-Layout (70/30) bleibt erhalten: links die Briefliste, rechts die Sidebar mit Personen und Status.</p>
|
||||
<p class="prose">Die linke Fläche zeigt: oben einen optionalen Einleitungs-Textarea (<code>body</code>), darunter die geordnete Itemliste, ganz unten eine Aktionsleiste mit „+ Brief hinzufügen" und „+ Zwischentext hinzufügen". Jedes Item hat einen Drag-Handle, eine Positionsnummer, den Inhalt und einen Entfernen-Button.</p>
|
||||
<p class="prose">Dokument-Items zeigen Titel und Kurz-Metadaten. Eine „Notiz hinzufügen/bearbeiten"-Aktion expandiert ein Textarea direkt unterhalb des Items — kein Modal, kein separates Formular. Interlude-Items (reiner Zwischentext) zeigen direkt ein editierbares Textarea mit orangenem Hintergrund zur klaren visuellen Unterscheidung.</p>
|
||||
<p class="prose">Speicheraktionen: Speichern (bei veröffentlichter Journey) oder „Entwurf speichern" + „Veröffentlichen" (bei DRAFT). Die Savebarlogik ist identisch zum GeschichteEditor. Alle Mutationen lösen sofort einen API-Call aus und aktualisieren den lokalen Zustand optimistisch — kein separates Save für einzelne Items.</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LE-1: EMPTY EDITOR ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Leerer Editor</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LE-1 — Journey-Editor leer</h3>
|
||||
<span class="scr-id">Issue #753 · LE-1</span>
|
||||
</div>
|
||||
<p class="scr-desc">Ausgangszustand einer neuen oder leeren Lesereise. Titel-Input oben. Darunter ein optionaler Einleitungs-Textarea. Leere Itemliste mit Leerstate-Text. Aktionsleiste mit zwei Buttons. Sidebar: Personen-Verknüpfung und Status-Anzeige. Keine Items → „Veröffentlichen" noch nicht aktiv (Disabled-Hint erscheint).</p>
|
||||
<p class="scr-var"><strong>Varianten:</strong> Neuer Entwurf ohne Titel (hier gezeigt) · Mit Titel, leere Liste</p>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||
<span class="bp-lbl">Desktop — 1040px · Neuer Entwurf</span>
|
||||
<div class="desk" style="min-height:500px;">
|
||||
<div class="fa-nav">
|
||||
<span class="fa-logo">ARCHIV</span>
|
||||
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||
<span class="fa-link">Dokumente</span>
|
||||
<span class="fa-link">Personen</span>
|
||||
<span class="fa-link active">Geschichten</span>
|
||||
<span class="fa-link">Chronik</span>
|
||||
<div class="fa-nav-r">
|
||||
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-topbar">
|
||||
<div class="ed-back">←</div>
|
||||
<div class="ed-title-label" style="display:flex;align-items:center;gap:6px;">
|
||||
Neue Lesereise
|
||||
<span style="font-size:7px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);background:var(--orange-tint);border:1px solid #F0C99A;padding:1px 5px;border-radius:3px;">REISE</span>
|
||||
</div>
|
||||
<div class="ed-status-pill ed-status-draft">ENTWURF</div>
|
||||
</div>
|
||||
<div class="ed-split">
|
||||
<!-- Left: Journey editor area -->
|
||||
<div class="je-main">
|
||||
<input class="je-title-input placeholder" type="text" value="" placeholder="Titel der Lesereise" readonly/>
|
||||
<div class="je-sep"></div>
|
||||
<div>
|
||||
<div class="je-intro-label">Einleitung (optional)</div>
|
||||
<textarea class="je-intro-area" placeholder="Optionaler Einleitungstext für diese Lesereise…" readonly></textarea>
|
||||
</div>
|
||||
<div class="je-sep"></div>
|
||||
<div class="je-list-label">Briefe & Zwischentexte</div>
|
||||
<div class="je-empty">
|
||||
<div class="je-empty-text">Noch keine Einträge. Füge den ersten Brief oder einen Zwischentext hinzu.</div>
|
||||
</div>
|
||||
<div class="je-add-bar">
|
||||
<button class="je-add-btn">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Brief hinzufügen
|
||||
</button>
|
||||
<button class="je-add-btn">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Zwischentext hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Sidebar -->
|
||||
<div class="ed-sidebar">
|
||||
<div class="ed-sb-section">
|
||||
<div class="ed-sb-title">Personen</div>
|
||||
<div class="ed-search-row">
|
||||
<span style="font-size:9px;color:var(--color-text-muted);">🔍</span>
|
||||
<div class="ed-search-input">Person suchen…</div>
|
||||
</div>
|
||||
<div class="ed-hint">Welche historischen Personen kommen in dieser Lesereise vor?</div>
|
||||
</div>
|
||||
<div class="ed-sb-divider"></div>
|
||||
<div class="ed-sb-section">
|
||||
<div class="ed-sb-title">Status</div>
|
||||
<div class="ed-status-pill ed-status-draft" style="font-size:9px;">ENTWURF</div>
|
||||
<div class="ed-hint" style="margin-top:6px;">Noch nicht öffentlich sichtbar. Füge mindestens einen Brief hinzu, um zu veröffentlichen.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-savebar">
|
||||
<span class="ed-savebar-hint">Alle Änderungen werden als Entwurf gespeichert.</span>
|
||||
<div class="ed-savebar-actions">
|
||||
<button class="ed-btn-ghost">Entwurf speichern</button>
|
||||
<button class="ed-btn-primary" style="opacity:.4;cursor:not-allowed;">Veröffentlichen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LE-1 Leerer Editor</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
|
||||
<tr><td>Bedingte Verzweigung</td><td>{#if geschichte.type === 'JOURNEY'}<JourneyEditor />{:else}<GeschichteEditor />{/if}</td><td>in edit/+page.svelte; Props: geschichte: Geschichte</td></tr>
|
||||
<tr><td>Split-Layout</td><td>flex flex-1 overflow-hidden (gleich wie GeschichteEditor)</td><td>70/30; Sidebar identisch</td></tr>
|
||||
<tr><td>Topbar-Badge</td><td>„REISE" Pill neben dem Titel-Label</td><td>orange; kein interaktives Element; zeigt Typ</td></tr>
|
||||
<tr class="grp"><td colspan="3">Titel-Input</td></tr>
|
||||
<tr><td>Titel-Input</td><td>font-serif text-2xl border-b border-line pb-2 w-full bg-transparent outline-none</td><td>bind:value={title}; gleiche Validierung wie GeschichteEditor (required)</td></tr>
|
||||
<tr class="grp"><td colspan="3">Einleitungs-Textarea</td></tr>
|
||||
<tr><td>Intro-Textarea</td><td>font-serif text-sm italic text-ink-3 leading-relaxed w-full resize-none bg-transparent outline-none border-none py-1</td><td>bind:value={body}; plaintext; auto-resize per rows-attr oder JS</td></tr>
|
||||
<tr><td>Label</td><td>text-[10px] font-bold uppercase tracking-widest text-ink-3 mb-1</td><td>„EINLEITUNG (OPTIONAL)"</td></tr>
|
||||
<tr class="grp"><td colspan="3">Leerstate</td></tr>
|
||||
<tr><td>Leerstate-Container</td><td>py-8 text-center border border-dashed border-line rounded-sm bg-surface</td><td>verschwindet sobald erstes Item vorhanden</td></tr>
|
||||
<tr><td>Leerstate-Text</td><td>font-serif text-xs text-ink-3 italic</td><td></td></tr>
|
||||
<tr class="grp"><td colspan="3">Veröffentlichen-Button</td></tr>
|
||||
<tr><td>Disabled-Zustand</td><td>disabled={items.length === 0 || !title.trim()}</td><td>opacity-40 + cursor-not-allowed; keine Tooltip nötig — Sidebar-Hint erklärt es</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LE-2: EDITOR WITH ITEMS ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Editor mit Einträgen</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LE-2 — Journey-Editor mit Einträgen</h3>
|
||||
<span class="scr-id">Issue #753 · LE-2</span>
|
||||
</div>
|
||||
<p class="scr-desc">Gefüllte Itemliste mit gemischten Typen: Dokument-Item ohne Notiz, Interlude-Item (reiner Zwischentext), Dokument-Item mit bestehender Notiz. Jedes Item zeigt Drag-Handle links, Positionsnummer, Inhalt und Entfernen-Button. Aktionsleiste bleibt unter der Liste sichtbar.</p>
|
||||
<p class="scr-var"><strong>Varianten:</strong> Veröffentlichte Journey (hier gezeigt) · Entwurf · Mobile</p>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||
<span class="bp-lbl">Desktop — 1040px · VERÖFFENTLICHT</span>
|
||||
<div class="desk" style="min-height:580px;">
|
||||
<div class="fa-nav">
|
||||
<span class="fa-logo">ARCHIV</span>
|
||||
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||
<span class="fa-link">Dokumente</span>
|
||||
<span class="fa-link">Personen</span>
|
||||
<span class="fa-link active">Geschichten</span>
|
||||
<div class="fa-nav-r">
|
||||
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">KR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-topbar">
|
||||
<div class="ed-back">←</div>
|
||||
<div class="ed-title-label" style="display:flex;align-items:center;gap:6px;">
|
||||
Lesereise bearbeiten
|
||||
<span style="font-size:7px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);background:var(--orange-tint);border:1px solid #F0C99A;padding:1px 5px;border-radius:3px;">REISE</span>
|
||||
</div>
|
||||
<div class="ed-status-pill ed-status-pub">VERÖFFENTLICHT</div>
|
||||
<span class="ed-delete-link">Löschen</span>
|
||||
</div>
|
||||
<div class="ed-split">
|
||||
<!-- Left: Journey editor area with items -->
|
||||
<div class="je-main">
|
||||
<input class="je-title-input" type="text" value="Briefe aus Breslau 1938–1942" readonly/>
|
||||
<div class="je-sep"></div>
|
||||
<div>
|
||||
<div class="je-intro-label">Einleitung (optional)</div>
|
||||
<textarea class="je-intro-area" readonly style="color:var(--color-text);">Der Briefwechsel zwischen Franz Raddatz und seiner Schwester Emma umspannt vier Jahre — von den letzten Friedenssommern bis zum Ende des Krieges.</textarea>
|
||||
</div>
|
||||
<div class="je-sep"></div>
|
||||
<div class="je-list-label">Briefe & Zwischentexte</div>
|
||||
|
||||
<!-- Item 1: Document, no note -->
|
||||
<div class="je-item">
|
||||
<div class="je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num">1</div>
|
||||
<div class="je-body">
|
||||
<div class="je-doc-title">Brief vom 12. Juli 1938</div>
|
||||
<div class="je-doc-meta">12. Juli 1938 · von Franz Raddatz an Emma Müller</div>
|
||||
<div class="je-note-add">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Notiz hinzufügen
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Item 2: Interlude -->
|
||||
<div class="je-item je-interlude-bg">
|
||||
<div class="je-drag" style="background:rgba(232,134,42,.08);border-right-color:#F0C99A;">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot" style="background:#D4A574;"></div><div class="je-drag-dot" style="background:#D4A574;"></div>
|
||||
<div class="je-drag-dot" style="background:#D4A574;"></div><div class="je-drag-dot" style="background:#D4A574;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num" style="color:var(--orange-dark);">
|
||||
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" style="margin-top:7px;"><path d="M2 4h8M2 7h5" stroke="var(--orange)" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<div class="je-body" style="padding-top:6px;">
|
||||
<div style="font-size:7.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);margin-bottom:4px;">Zwischentext</div>
|
||||
<textarea class="je-interlude-area" readonly>Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde.</textarea>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x" style="color:#D4A574;">×</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Item 3: Document with note -->
|
||||
<div class="je-item">
|
||||
<div class="je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num">2</div>
|
||||
<div class="je-body">
|
||||
<div class="je-doc-title">Postkarte aus Breslau, August 1938</div>
|
||||
<div class="je-doc-meta" style="margin-bottom:5px;">22. Aug. 1938 · von Franz Raddatz an Emma Müller</div>
|
||||
<textarea class="je-note-area" readonly>Diese Karte ist ungewöhnlich kurz für Franz — vier Zeilen, fast hastig. Ein Zeichen der aufkommenden Unruhe in den Nachrichten?</textarea>
|
||||
<div class="je-note-add" style="margin-top:3px;color:var(--color-text-muted);">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
Notiz entfernen
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Item 4: Document, no note -->
|
||||
<div class="je-item">
|
||||
<div class="je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num">3</div>
|
||||
<div class="je-body">
|
||||
<div class="je-doc-title">Brief vom 3. September 1939</div>
|
||||
<div class="je-doc-meta">3. Sept. 1939 · von Emma Müller an Franz Raddatz</div>
|
||||
<div class="je-note-add">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Notiz hinzufügen
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Add bar -->
|
||||
<div class="je-add-bar">
|
||||
<button class="je-add-btn">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Brief hinzufügen
|
||||
</button>
|
||||
<button class="je-add-btn">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Zwischentext hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Sidebar -->
|
||||
<div class="ed-sidebar">
|
||||
<div class="ed-sb-section">
|
||||
<div class="ed-sb-title">Personen</div>
|
||||
<div class="ed-search-row">
|
||||
<span style="font-size:9px;color:var(--color-text-muted);">🔍</span>
|
||||
<div class="ed-search-input">Person suchen…</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;margin-top:4px;">
|
||||
<span class="ed-chip">
|
||||
<span style="width:10px;height:10px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:var(--mint);">FR</span>
|
||||
Franz Raddatz
|
||||
<span class="ed-chip-x">×</span>
|
||||
</span>
|
||||
<span class="ed-chip">
|
||||
<span style="width:10px;height:10px;border-radius:50%;background:#534AB7;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:#fff;">EM</span>
|
||||
Emma Müller
|
||||
<span class="ed-chip-x">×</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-sb-divider"></div>
|
||||
<div class="ed-sb-section">
|
||||
<div class="ed-sb-title">Status</div>
|
||||
<div class="ed-status-pill ed-status-pub" style="font-size:9px;">VERÖFFENTLICHT</div>
|
||||
<div class="ed-hint" style="margin-top:6px;">Änderungen gehen sofort live.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-savebar">
|
||||
<span class="ed-savebar-hint">Änderungen sofort live — Leser sehen die aktuelle Version.</span>
|
||||
<div class="ed-savebar-actions">
|
||||
<button class="ed-btn-ghost retract">Zurück zu Entwurf</button>
|
||||
<button class="ed-btn-primary">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LE-2 Items-Liste</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Item-Zeile allgemein</td></tr>
|
||||
<tr><td>Item-Container</td><td>flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden</td><td>interlude: bg-orange-50 border-orange-200</td></tr>
|
||||
<tr><td>Drag-Handle</td><td>w-4 bg-surface border-r border-line flex items-center justify-center cursor-grab shrink-0</td><td>aria-label="Reihenfolge ändern"; cursor-grabbing während Drag</td></tr>
|
||||
<tr><td>Positions-Nr.</td><td>w-5 text-[10px] font-bold text-ink-3 flex items-start justify-center pt-2 shrink-0</td><td>aus Array-Index, nicht item.position</td></tr>
|
||||
<tr><td>Entfernen-Button</td><td>w-6 flex items-start justify-center pt-2 shrink-0</td><td>× aria-label="Eintrag entfernen"; hover: text-red-500; Confirm nur wenn note vorhanden</td></tr>
|
||||
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
|
||||
<tr><td>Brieftitel</td><td>text-[11px] font-semibold text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
||||
<tr><td>Briefmeta</td><td>text-xs text-ink-3</td><td>formatDate(doc.documentDate) · "von X" oder "von X an Y"</td></tr>
|
||||
<tr><td>Notiz-Textarea (sichtbar)</td><td>w-full min-h-[40px] font-serif text-xs italic bg-surface border border-line rounded-sm p-1.5 resize-none focus:border-primary focus:bg-white mt-2</td><td>auto-expand; bind:value={item.note}</td></tr>
|
||||
<tr><td>„Notiz hinzufügen" Link</td><td>text-xs font-semibold text-blue-600 inline-flex items-center gap-1 mt-1</td><td>togglet Notiz-Textarea</td></tr>
|
||||
<tr><td>„Notiz entfernen" Link</td><td>text-xs text-ink-3 inline-flex items-center gap-1 mt-1</td><td>zeigt sich wenn note.trim() nicht leer; setzt note = '' und blendet Textarea aus</td></tr>
|
||||
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
|
||||
<tr><td>Interlude-Container</td><td>bg-orange-50 border-orange-200 (überschreibt Item-Container)</td><td>kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl</td></tr>
|
||||
<tr><td>Label „Zwischentext"</td><td>text-[9px] font-bold uppercase tracking-widest text-orange-700 mb-1</td><td>immer sichtbar; nicht editierbar</td></tr>
|
||||
<tr><td>Zwischentext-Textarea</td><td>w-full min-h-[44px] font-serif text-xs italic bg-white/60 border border-orange-200 rounded-sm p-1.5 resize-none focus:border-orange-400</td><td>bind:value={item.note}; auto-expand; min 44px für Touch-Target</td></tr>
|
||||
<tr class="grp"><td colspan="3">Aktionsleiste</td></tr>
|
||||
<tr><td>Add Bar</td><td>flex gap-2 pt-2 pb-1</td><td>immer unten sichtbar, auch wenn Liste gefüllt</td></tr>
|
||||
<tr><td>„Brief hinzufügen" Button</td><td>border border-dashed border-line rounded-sm px-3 py-1.5 text-xs font-semibold text-ink-2 hover:border-primary hover:text-primary flex items-center gap-1</td><td>öffnet existierende DocumentPicker-Komponente als Dropdown/Modal</td></tr>
|
||||
<tr><td>„Zwischentext hinzufügen" Button</td><td>gleich wie Brief-Button</td><td>fügt neues Interlude-Item am Ende ein; Fokus auf das neue Textarea</td></tr>
|
||||
<tr class="grp"><td colspan="3">Drag-to-Reorder</td></tr>
|
||||
<tr><td>Bibliothek</td><td>@dnd-kit/core oder svelte-dnd-action (bereits im Projekt prüfen)</td><td>kein neues Package ohne Absprache</td></tr>
|
||||
<tr><td>Reorder-API-Call</td><td>PUT /api/geschichten/{id}/items/reorder — body: [{id, position}] für alle Items</td><td>nach jedem Drop ausgelöst; optimistisch: lokalen State sofort aktualisieren</td></tr>
|
||||
<tr><td>Accessibility</td><td>Drag-Handle: role="button" tabIndex=0; Keyboard: Space startet Drag, Arrow hoch/runter verschiebt, Space/Enter bestätigt, Esc abbricht</td><td>WCAG 2.1 SC 2.1.1</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LE-3: INLINE NOTE EDITING ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Inline-Notiz-Editing</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LE-3 — Notiz-Textarea wird geöffnet</h3>
|
||||
<span class="scr-id">Issue #753 · LE-3</span>
|
||||
</div>
|
||||
<p class="scr-desc">Wenn der Nutzer auf „Notiz hinzufügen" klickt, expandiert das Item um ein Textarea direkt unterhalb der Briefmeta — kein Modal. Der Fokus springt automatisch in das Textarea. Das Textarea hat einen blauen Fokusring als Orientierungshilfe. Ein API-PATCH wird beim Verlassen des Textareas (blur) ausgelöst, nicht bei jedem Tastendruck.</p>
|
||||
<p class="scr-var"><strong>Inset-Ansicht — kein vollständiger Seiten-Mockup nötig</strong></p>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col" style="width:100%;max-width:560px;">
|
||||
<span class="bp-lbl">Inset — Notiz-Textarea geöffnet (Fokus)</span>
|
||||
<div style="background:#E8E7E2;padding:16px;border-radius:var(--radius-xl);">
|
||||
<!-- Item before (no note) -->
|
||||
<div class="je-item" style="margin-bottom:5px;">
|
||||
<div class="je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num">1</div>
|
||||
<div class="je-body">
|
||||
<div class="je-doc-title">Brief vom 12. Juli 1938</div>
|
||||
<div class="je-doc-meta">12. Juli 1938 · Franz → Emma</div>
|
||||
<div class="je-note-add">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Notiz hinzufügen
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Item with opened note textarea (focused) -->
|
||||
<div class="je-item">
|
||||
<div class="je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num">2</div>
|
||||
<div class="je-body">
|
||||
<div class="je-doc-title">Postkarte aus Breslau, August 1938</div>
|
||||
<div class="je-doc-meta" style="margin-bottom:5px;">22. Aug. 1938 · Franz → Emma</div>
|
||||
<!-- Focused textarea -->
|
||||
<textarea class="je-note-area je-note-editing" style="outline:none;box-shadow:0 0 0 2px rgba(1,40,81,.2);" readonly placeholder="Kuratoren-Notiz für diesen Brief…">|</textarea>
|
||||
<div style="font-size:7px;color:var(--color-text-muted);margin-top:3px;">Wird gespeichert, wenn du das Feld verlässt.</div>
|
||||
<div class="je-note-add" style="color:var(--color-text-muted);margin-top:2px;">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
Notiz entfernen
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LE-3 Inline-Notiz</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Toggleverhalten</td></tr>
|
||||
<tr><td>Lokaler State</td><td>let noteOpen = item.note !== null and item.note !== ''</td><td>öffnet sich automatisch wenn Notiz bereits vorhanden</td></tr>
|
||||
<tr><td>„Notiz hinzufügen" Klick</td><td>noteOpen = true; tick().then(() => noteTextarea.focus())</td><td>Fokus nach Svelte-Tick um DOM-Update abzuwarten</td></tr>
|
||||
<tr><td>Textarea blur-Handler</td><td>on:blur={() => saveNote(item.id, note)}</td><td>PATCH /api/geschichten/{id}/items/{itemId} mit {note}</td></tr>
|
||||
<tr><td>Leere Notiz on blur</td><td>wenn note.trim() === '' → noteOpen = false; note = null</td><td>verhindert leere Notizen im Backend</td></tr>
|
||||
<tr class="grp"><td colspan="3">Fokus-Styling</td></tr>
|
||||
<tr><td>Fokus-Ring</td><td>focus:border-primary focus:ring-2 focus:ring-primary/20 focus:bg-white</td><td>sichtbarer Ring für Keyboard-Navigation; ring-offset für Abstand</td></tr>
|
||||
<tr><td>Spar-Hint</td><td>text-[9px] text-ink-3 mt-1</td><td>„Wird gespeichert, wenn du das Feld verlässt."; verschwindet wenn noteOpen = false</td></tr>
|
||||
<tr class="grp"><td colspan="3">Barrierefreiheit</td></tr>
|
||||
<tr><td>aria-label Textarea</td><td>aria-label="Kuratoren-Notiz für {document.title}"</td><td>spezifisch; Screen-Reader nennt Brief-Kontext</td></tr>
|
||||
<tr><td>aria-expanded Toggle</td><td>aria-expanded={noteOpen} auf „Notiz hinzufügen"-Button</td><td>kommuniziert Expand-State</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LE-4: MOBILE ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Mobile Editor</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LE-4 — Mobile Journey-Editor</h3>
|
||||
<span class="scr-id">Issue #753 · LE-4</span>
|
||||
</div>
|
||||
<p class="scr-desc">Auf Mobile (320px) entfällt die Sidebar-Split. Die Personen- und Status-Sektion werden als ausklappbare Sektionen unter der Itemliste gezeigt. Drag-to-Reorder ist auf Mobile durch Long-Press aktiviert. Die Aktionsleiste scrollt mit dem Inhalt.</p>
|
||||
<p class="scr-var"><strong>Primäre Zielgruppe für den Editor: Desktop/Tablet. Mobile ist sekundär — alle Funktionen erreichbar, aber Drag ist schwerer bedienbar.</strong></p>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<span class="bp-lbl">Mobile — 320px · mit Einträgen</span>
|
||||
<div class="phone" style="min-height:580px;">
|
||||
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||
<div class="pb">
|
||||
<div class="m-nav">
|
||||
<span class="m-logo">ARCHIV</span>
|
||||
<div class="m-nav-r">
|
||||
<div class="m-av">KR</div>
|
||||
<div class="m-ham"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mob-topbar">
|
||||
<span class="mob-back">←</span>
|
||||
<span class="mob-label">Lesereise bearbeiten</span>
|
||||
<div class="ed-status-pill ed-status-pub" style="font-size:7px;padding:1px 5px;">VERÖFF.</div>
|
||||
</div>
|
||||
<div class="mob-body">
|
||||
<input class="mob-title-input" type="text" value="Briefe aus Breslau 1938–1942" readonly/>
|
||||
|
||||
<!-- Item 1: Document -->
|
||||
<div class="mob-je-item">
|
||||
<div class="mob-je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mob-je-body">
|
||||
<div class="mob-je-title">Brief vom 12. Juli 1938</div>
|
||||
<div class="mob-je-meta">12. Juli 1938 · Franz → Emma</div>
|
||||
</div>
|
||||
<div style="padding:6px 6px 0 0;font-size:10px;color:#C4C3BC;">×</div>
|
||||
</div>
|
||||
|
||||
<!-- Item 2: Interlude -->
|
||||
<div class="mob-je-item mob-je-interlude">
|
||||
<div class="mob-je-drag" style="background:rgba(232,134,42,.08);border-right-color:#F0C99A;"></div>
|
||||
<div class="mob-je-body">
|
||||
<div style="font-size:6.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);margin-bottom:3px;">Zwischentext</div>
|
||||
<div class="mob-je-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht…</div>
|
||||
</div>
|
||||
<div style="padding:6px 6px 0 0;font-size:10px;color:#D4A574;">×</div>
|
||||
</div>
|
||||
|
||||
<!-- Item 3: Document with note -->
|
||||
<div class="mob-je-item">
|
||||
<div class="mob-je-drag"></div>
|
||||
<div class="mob-je-body">
|
||||
<div class="mob-je-title">Postkarte Aug. 1938</div>
|
||||
<div class="mob-je-meta">22. Aug. 1938 · Franz → Emma</div>
|
||||
<div class="mob-je-note">Diese Karte ist ungewöhnlich kurz für Franz…</div>
|
||||
</div>
|
||||
<div style="padding:6px 6px 0 0;font-size:10px;color:#C4C3BC;">×</div>
|
||||
</div>
|
||||
|
||||
<!-- Add bar -->
|
||||
<div style="display:flex;gap:5px;padding:4px 0;">
|
||||
<button class="je-add-btn" style="flex:1;font-size:7.5px;padding:6px 8px;justify-content:center;">+ Brief</button>
|
||||
<button class="je-add-btn" style="flex:1;font-size:7.5px;padding:6px 8px;justify-content:center;">+ Zwischentext</button>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible: Personen -->
|
||||
<div class="mob-collapsible">
|
||||
<div class="mob-coll-hdr">
|
||||
Personen
|
||||
<span class="mob-coll-chevron">›</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Collapsible: Status -->
|
||||
<div class="mob-collapsible">
|
||||
<div class="mob-coll-hdr">
|
||||
Status & Speichern
|
||||
<span class="mob-coll-chevron">›</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mob-savebar">
|
||||
<button class="mob-btn mob-btn-ghost" style="font-size:8px;flex:0 0 auto;padding:7px 10px;">Entwurf</button>
|
||||
<button class="mob-btn mob-btn-primary">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LE-4 Mobile</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Layout-Anpassungen</td></tr>
|
||||
<tr><td>Split entfällt</td><td>@media (max-width: 768px): flex-col; Sidebar-Sektionen als Collapsibles am Ende</td><td>gleich wie GeschichteEditor auf Mobile</td></tr>
|
||||
<tr><td>Collapsibles</td><td>details/summary oder eigene boolean-Toggle; Personen + Status separat</td><td>geschlossen beim ersten Laden; Fokus öffnet</td></tr>
|
||||
<tr class="grp"><td colspan="3">Touch & Drag</td></tr>
|
||||
<tr><td>Drag auf Mobile</td><td>Long-Press (500ms) auf dem Drag-Handle aktiviert Drag</td><td>dnd-kit unterstützt Touch nativ; kein separates Config nötig</td></tr>
|
||||
<tr><td>Touch Target Items</td><td>min-h-[44px] für jede Item-Zeile</td><td>WCAG 2.2 AA; durch Padding gesichert</td></tr>
|
||||
<tr><td>Add-Buttons</td><td>flex-1; volle verfügbare Breite geteilt</td><td>min-h-[44px] als Touch-Target</td></tr>
|
||||
<tr class="grp"><td colspan="3">Savebar</td></tr>
|
||||
<tr><td>Savebar Mobile</td><td>flex gap-2; „Zurück zu Entwurf" komprimiert zu „Entwurf"</td><td>Volltext passt nicht auf 320px</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||
<div class="llm">
|
||||
<h2>Implementation Guide — Journey-Editor</h2>
|
||||
|
||||
<h3>Neue Komponente</h3>
|
||||
<table>
|
||||
<thead><tr><th>Datei</th><th>Typ</th><th>Beschreibung</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>src/lib/geschichte/JourneyEditor.svelte</code></td><td>Svelte-Komponente</td><td>Hauptkomponente; Props: <code>geschichte: Geschichte</code></td></tr>
|
||||
<tr><td><code>src/lib/geschichte/JourneyItemRow.svelte</code></td><td>Svelte-Komponente</td><td>Eine Zeile (Dokument oder Interlude); Props: <code>item: JourneyItem, position: number</code>, Events: <code>remove, noteChange</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Edit-Page-Integration</h3>
|
||||
<ul>
|
||||
<li><code>GeschichteEditor.svelte</code> erhält ein neues Prop <code>type: GeschichteType</code>.</li>
|
||||
<li>Wenn <code>type === 'JOURNEY'</code>: rendere <code>JourneyEditor</code> statt TipTap-Editor. Die Sidebar (Personen, Status, Savebar) bleibt identisch.</li>
|
||||
<li>Die Savebar-Logik ist in der Edit-Page (<code>+page.svelte</code>) verankert — <code>JourneyEditor</code> gibt nur Änderungen nach oben (Svelte-Events oder bindable Props), die Seite hält den Save-State.</li>
|
||||
</ul>
|
||||
|
||||
<h3>API-Calls</h3>
|
||||
<table>
|
||||
<thead><tr><th>Aktion</th><th>Endpoint</th><th>Body</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Brief hinzufügen</td><td><code>POST /api/geschichten/{id}/items</code></td><td><code>{documentId: UUID}</code></td></tr>
|
||||
<tr><td>Zwischentext hinzufügen</td><td><code>POST /api/geschichten/{id}/items</code></td><td><code>{note: string}</code></td></tr>
|
||||
<tr><td>Notiz speichern/bearbeiten</td><td><code>PATCH /api/geschichten/{id}/items/{itemId}</code></td><td><code>{note: string | null}</code></td></tr>
|
||||
<tr><td>Item entfernen</td><td><code>DELETE /api/geschichten/{id}/items/{itemId}</code></td><td>—</td></tr>
|
||||
<tr><td>Reihenfolge speichern</td><td><code>PUT /api/geschichten/{id}/items/reorder</code></td><td><code>[{id: UUID, position: number}]</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Optimistische Updates</h3>
|
||||
<ul>
|
||||
<li>Alle Mutationen (add, remove, reorder, noteChange) aktualisieren den lokalen State <em>sofort</em>, der API-Call läuft parallel.</li>
|
||||
<li>Bei Fehler: lokalen State zurückrollen und einen <code>aria-live="polite"</code>-Fehlerhinweis anzeigen.</li>
|
||||
<li>Notiz-Saving ist ein Sonderfall: es gibt kein optimistisches Update da der Wert bereits live im Textarea ist — nur blur → PATCH.</li>
|
||||
</ul>
|
||||
|
||||
<h3>DocumentPicker-Integration</h3>
|
||||
<ul>
|
||||
<li>Der „Brief hinzufügen"-Button öffnet die bestehende <code>DocumentPicker</code>-Komponente (prüfe <code>$lib/document/</code> auf vorhandene Typeahead-Komponenten).</li>
|
||||
<li>Nach Auswahl eines Dokuments: <code>POST /items</code> mit <code>documentId</code>, neues Item wird an das Ende der Liste angehängt und eingeblendet.</li>
|
||||
<li>Bereits in der Journey enthaltene Dokumente: in der Picker-Ergebnisliste mit einem „Bereits enthalten"-Hinweis markieren und deaktivieren.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Drag-to-Reorder</h3>
|
||||
<ul>
|
||||
<li>Bibliothek: prüfe zunächst ob <code>@dnd-kit/core</code> oder <code>svelte-dnd-action</code> bereits im <code>package.json</code> ist. Kein neues Package einführen ohne Absprache.</li>
|
||||
<li>Nach dem Drop: neue Reihenfolge als Array <code>[{id, position}]</code> berechnen (position = index * 10 lässt Lücken für künftige Inserts) und <code>PUT /items/reorder</code> senden.</li>
|
||||
<li>Keyboard-Drag: Space/Enter startet, Arrow Up/Down verschiebt, Space/Enter bestätigt, Escape abbricht. Screenreader-Announcement: „Eintrag X von Position Y nach Z verschoben".</li>
|
||||
</ul>
|
||||
|
||||
<h3>Barrierefreiheit</h3>
|
||||
<ul>
|
||||
<li>Items-Liste: <code><ol></code>-Element — kommuniziert die Ordnung an Screenreader.</li>
|
||||
<li>Drag-Handle: <code>role="button"</code>, <code>tabindex="0"</code>, <code>aria-label="Reihenfolge von '{title}' ändern"</code>.</li>
|
||||
<li>Entfernen-Button: <code>aria-label="'{title}' entfernen"</code>; kein reines ×-Zeichen ohne Label.</li>
|
||||
<li>Notiz-Textarea: <code>aria-label="Kuratoren-Notiz für '{title}'"</code>.</li>
|
||||
<li>Touch-Targets: alle interaktiven Elemente min 44×44px (WCAG 2.2 AA).</li>
|
||||
<li>Fokusring: <code>focus-visible:ring-2 focus-visible:ring-primary</code> auf allen Buttons und Textareas.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Abgrenzung zu GeschichteEditor</h3>
|
||||
<ul>
|
||||
<li>TipTap wird für JOURNEY <em>nicht</em> geladen — kein unnötiger Bundle-Load.</li>
|
||||
<li>Die Sidebar (Personen, Status) ist für beide Typen identisch — kein Duplikat, die Sidebar-Komponente wird geteilt.</li>
|
||||
<li>Savebar-Logik (DRAFT/PUBLISHED/Retract) ist identisch — JourneyEditor ändert sie nicht.</li>
|
||||
<li><code>Geschichte.body</code> dient für JOURNEY als Einleitungstext (Plaintext, kein HTML). Kein Rich-Text-Rendering auf der Leseseite nötig.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
727
docs/specs/lesereisen-reader-spec.html
Normal file
727
docs/specs/lesereisen-reader-spec.html
Normal file
@@ -0,0 +1,727 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Lesereisen — Reader-Integration · Familienarchiv</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
|
||||
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||
.pill-o{background:var(--orange-tint);color:var(--orange-dark);}
|
||||
.section{margin-bottom:64px;}
|
||||
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
|
||||
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
|
||||
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
|
||||
.jh-o{background:var(--orange-tint);border:1px solid #F0C99A;}
|
||||
.jh-o .jn{color:var(--orange);}
|
||||
.jh-o p,.jh-o .fl{color:var(--orange-dark);}
|
||||
.scr{margin-bottom:56px;}
|
||||
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;}
|
||||
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
|
||||
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
|
||||
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.fa-link.active{color:var(--mint);}
|
||||
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
|
||||
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
.m-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0;}
|
||||
.m-logo{font-size:6px;font-weight:900;color:#fff;letter-spacing:.7px;border-bottom:1.5px solid var(--mint);padding-bottom:1px;}
|
||||
.m-nav-r{margin-left:auto;display:flex;gap:4px;align-items:center;}
|
||||
.m-av{width:14px;height:14px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
.m-ham{display:flex;flex-direction:column;gap:2px;width:12px;}
|
||||
.m-ham span{height:1.5px;background:rgba(255,255,255,.6);border-radius:1px;}
|
||||
|
||||
/* ── impl-ref table ── */
|
||||
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||
.at thead tr{border-bottom:1px solid #2A2A26;}
|
||||
.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}
|
||||
.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}
|
||||
.at tr:last-child td{border-bottom:none;}
|
||||
.at td:first-child{color:#7A7A72;}
|
||||
.at td:nth-child(2){color:#E8E8E2;font-weight:500;}
|
||||
.at td:nth-child(3){color:#5A5A55;}
|
||||
.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||
|
||||
/* ── LLM guide ── */
|
||||
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
|
||||
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;}
|
||||
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
|
||||
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||
.llm li{margin-bottom:4px;}
|
||||
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.llm td{color:var(--color-text-muted);}
|
||||
|
||||
/* ── List row (re-used from reader-journey spec) ── */
|
||||
.g-list-card{background:#fff;border:1px solid #E4E2D7;border-radius:4px;box-shadow:var(--shadow-card);overflow:hidden;}
|
||||
.g-row{display:flex;gap:0;border-bottom:1px solid #F0EFE9;}
|
||||
.g-row:last-child{border-bottom:none;}
|
||||
.g-meta{width:88px;flex-shrink:0;padding:10px 10px 10px 12px;display:flex;flex-direction:column;gap:3px;border-right:1px solid #F0EFE9;}
|
||||
.g-content{padding:10px 14px 10px 12px;flex:1;min-width:0;}
|
||||
.g-av{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:800;color:#fff;flex-shrink:0;margin-bottom:3px;}
|
||||
.av-navy{background:#012851;} .av-purple{background:#534AB7;} .av-teal{background:#0E9488;}
|
||||
.g-author{font-size:7px;font-weight:700;color:#1C1C18;line-height:1.3;}
|
||||
.g-date{font-size:6.5px;color:#6B6A63;}
|
||||
.g-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px;background:#F5F4EE;border:1px solid #D8D7D0;border-radius:10px;font-size:6px;font-weight:500;color:#1C1C18;margin-top:2px;max-width:76px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.g-title{font-family:Georgia,serif;font-size:11px;color:#012851;line-height:1.4;margin-bottom:2px;}
|
||||
.g-excerpt{font-size:7.5px;color:#6B6A63;line-height:1.55;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
|
||||
.g-filters{display:flex;gap:5px;align-items:center;padding:8px 12px;background:var(--color-page);border-bottom:1px solid #EDECEA;flex-wrap:wrap;}
|
||||
.g-pill{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:6.5px;font-weight:700;border:1px solid #D8D7D0;color:#6B6A63;background:transparent;}
|
||||
.g-pill.active{background:#012851;color:#fff;border-color:#012851;}
|
||||
.g-page-hdr{display:flex;justify-content:space-between;align-items:center;padding:10px 14px 6px;}
|
||||
.g-page-title{font-family:Georgia,serif;font-size:16px;font-weight:400;color:#012851;}
|
||||
.g-new-btn{font-size:7px;font-weight:700;padding:4px 10px;border-radius:3px;background:#012851;color:#fff;border:none;display:flex;align-items:center;gap:3px;}
|
||||
|
||||
/* ── Journey badge in list ── */
|
||||
.j-badge{display:inline-flex;align-items:center;padding:1px 5px;border-radius:3px;font-size:5.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-top:2px;}
|
||||
|
||||
/* ── Type selector cards ── */
|
||||
.type-selector{display:flex;gap:12px;justify-content:center;padding:20px 24px;flex:1;align-items:center;background:#E8E7E2;}
|
||||
.type-selector-inner{max-width:520px;width:100%;}
|
||||
.type-selector-q{font-family:Georgia,serif;font-size:12px;font-weight:400;color:#6B6A63;text-align:center;margin-bottom:14px;}
|
||||
.type-cards{display:flex;gap:10px;}
|
||||
.type-card{flex:1;border:1px solid #D8D7D0;border-radius:6px;padding:12px 14px;cursor:pointer;background:#fff;display:flex;flex-direction:column;gap:5px;}
|
||||
.type-card.selected{border-color:var(--orange);background:var(--orange-tint);box-shadow:0 0 0 2px rgba(232,134,42,.15);}
|
||||
.type-card-icon{font-size:16px;margin-bottom:2px;}
|
||||
.type-card-title{font-family:Georgia,serif;font-size:11px;font-weight:400;color:var(--navy);}
|
||||
.type-card-desc{font-size:7.5px;color:#6B6A63;line-height:1.55;}
|
||||
.type-card-check{width:14px;height:14px;border-radius:50%;background:var(--orange);display:flex;align-items:center;justify-content:center;margin-top:4px;align-self:flex-end;}
|
||||
.type-card-check svg{width:8px;height:8px;}
|
||||
.type-next-bar{display:flex;justify-content:flex-end;padding:8px 24px;background:#fff;border-top:1px solid #E4E2D7;}
|
||||
.type-next-btn{font-size:8px;font-weight:700;padding:5px 14px;border-radius:3px;background:var(--navy);color:#fff;border:none;display:flex;align-items:center;gap:3px;}
|
||||
|
||||
/* ── Journey reader ── */
|
||||
.jr-article{background:var(--color-page);border-radius:6px;padding:16px 20px;max-width:640px;margin:0 auto;}
|
||||
.jr-back{font-size:7px;color:#6B6A63;margin-bottom:10px;display:flex;align-items:center;gap:2px;}
|
||||
.jr-badge{display:inline-flex;align-items:center;padding:1px 6px;border-radius:3px;font-size:6px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-bottom:5px;}
|
||||
.jr-title{font-family:Georgia,serif;font-size:18px;font-weight:400;color:#012851;line-height:1.3;margin-bottom:8px;}
|
||||
.jr-metabar{display:flex;align-items:center;gap:6px;padding-bottom:8px;border-bottom:1px solid #EDECEA;margin-bottom:10px;}
|
||||
.jr-metabar-r{margin-left:auto;display:flex;align-items:center;gap:6px;}
|
||||
.jr-edit-btn{font-size:6.5px;font-weight:600;padding:2px 7px;border:1px solid #D8D7D0;border-radius:3px;color:#1C1C18;background:transparent;}
|
||||
.jr-intro{font-family:Georgia,serif;font-size:8.5px;line-height:1.75;color:#6B6A63;font-style:italic;margin-bottom:12px;padding-bottom:10px;border-bottom:1px dashed #EDECEA;}
|
||||
|
||||
/* Journey items in reader */
|
||||
.jr-item{display:flex;gap:7px;margin-bottom:9px;align-items:flex-start;}
|
||||
.jr-num{width:18px;height:18px;border-radius:50%;background:#012851;color:#fff;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:700;flex-shrink:0;margin-top:1px;}
|
||||
.jr-card{flex:1;background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:7px 9px;}
|
||||
.jr-card-title{font-family:Georgia,serif;font-size:9px;color:#012851;line-height:1.3;margin-bottom:2px;font-weight:400;}
|
||||
.jr-card-meta{font-size:6.5px;color:#6B6A63;margin-bottom:5px;}
|
||||
.jr-card-link{font-size:7px;font-weight:600;color:#012851;display:flex;align-items:center;gap:2px;}
|
||||
.jr-annotation{margin-top:6px;padding:5px 7px;border-left:2px solid var(--mint);background:#F5F4EE;border-radius:0 3px 3px 0;}
|
||||
.jr-annotation-text{font-size:7.5px;font-style:italic;color:#6B6A63;line-height:1.55;}
|
||||
.jr-interlude{margin:10px 0 10px 25px;padding:7px 9px;border-left:2px solid var(--orange);background:var(--orange-tint);border-radius:0 4px 4px 0;}
|
||||
.jr-interlude-text{font-size:8px;font-style:italic;color:#1C1C18;line-height:1.65;}
|
||||
|
||||
/* Mobile list row */
|
||||
.m-row{padding:9px 10px;border-bottom:1px solid #F0EFE9;background:#fff;}
|
||||
.m-row-top{display:flex;align-items:center;gap:5px;margin-bottom:3px;}
|
||||
.m-author-name{font-size:7px;font-weight:700;color:#1C1C18;}
|
||||
.m-date{font-size:6.5px;color:#6B6A63;margin-left:auto;}
|
||||
.m-title{font-family:Georgia,serif;font-size:10px;color:#012851;line-height:1.4;margin-bottom:2px;}
|
||||
.m-excerpt{font-size:7px;color:#6B6A63;line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
|
||||
.m-filters{display:flex;gap:4px;padding:6px 10px;background:var(--color-page);border-bottom:1px solid #EDECEA;overflow-x:auto;flex-wrap:nowrap;}
|
||||
.m-filters::-webkit-scrollbar{display:none;}
|
||||
|
||||
/* Mobile journey reader */
|
||||
.mjr-article{background:#fff;border-radius:6px;padding:12px 12px 16px;}
|
||||
.mjr-back{font-size:7px;color:#6B6A63;margin-bottom:7px;display:flex;align-items:center;gap:2px;}
|
||||
.mjr-badge{display:inline-flex;padding:1px 5px;border-radius:3px;font-size:5.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-bottom:4px;}
|
||||
.mjr-title{font-family:Georgia,serif;font-size:14px;font-weight:400;color:#012851;line-height:1.3;margin-bottom:6px;}
|
||||
.mjr-metabar{display:flex;align-items:center;gap:5px;padding-bottom:6px;border-bottom:1px solid #EDECEA;margin-bottom:8px;}
|
||||
.mjr-intro{font-family:Georgia,serif;font-size:8px;line-height:1.7;color:#6B6A63;font-style:italic;margin-bottom:9px;padding-bottom:7px;border-bottom:1px dashed #EDECEA;}
|
||||
.mjr-item{display:flex;gap:5px;margin-bottom:7px;align-items:flex-start;}
|
||||
.mjr-num{width:14px;height:14px;border-radius:50%;background:#012851;color:#fff;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;flex-shrink:0;margin-top:1px;}
|
||||
.mjr-card{flex:1;background:#F5F4EE;border:1px solid #E4E2D7;border-radius:4px;padding:5px 7px;}
|
||||
.mjr-card-title{font-family:Georgia,serif;font-size:8.5px;color:#012851;line-height:1.3;margin-bottom:1px;}
|
||||
.mjr-card-meta{font-size:6px;color:#6B6A63;margin-bottom:4px;}
|
||||
.mjr-card-link{font-size:6.5px;font-weight:600;color:#012851;}
|
||||
.mjr-interlude{margin:7px 0 7px 19px;padding:5px 7px;border-left:2px solid var(--orange);background:var(--orange-tint);border-radius:0 3px 3px 0;}
|
||||
.mjr-interlude-text{font-size:7.5px;font-style:italic;color:#1C1C18;line-height:1.6;}
|
||||
|
||||
/* ── Editor topbar (type selector screen) ── */
|
||||
.ed-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 14px;gap:8px;height:38px;flex-shrink:0;}
|
||||
.ed-back{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);flex-shrink:0;}
|
||||
.ed-title-label{font-family:var(--font-sans);font-size:10px;font-weight:500;color:var(--color-text);flex:1;}
|
||||
.ed-status-pill{display:inline-flex;align-items:center;padding:2px 7px;border-radius:20px;font-size:8px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;flex-shrink:0;}
|
||||
.ed-status-draft{background:#F0EFE9;color:#6B6A63;border:1px solid #D8D7D0;}
|
||||
|
||||
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<!-- ═══ DOC HEADER ═══ -->
|
||||
<div class="doc-header">
|
||||
<div>
|
||||
<h1>Lesereisen — Reader-Integration</h1>
|
||||
<p>Typauswahl bei <code>/geschichten/new</code>, Journey-Badge auf der Übersichtsliste und die neue geordnete Leseansicht auf <code>/geschichten/[id]</code> wenn <code>type === 'JOURNEY'</code>. Bestehende Story-Ansichten bleiben unverändert.</p>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
Familienarchiv<br/>
|
||||
<span class="pill pill-o">Final Spec</span><br/>
|
||||
2026-06-07 · @leonievoss<br/>
|
||||
<span style="font-size:10px;margin-top:4px;display:inline-block;">Issue #752</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ JOURNEY HEADER ═══ -->
|
||||
<div class="jh jh-o">
|
||||
<div class="jn">R</div>
|
||||
<div>
|
||||
<h2>Lesereisen — Reader</h2>
|
||||
<p>Alle angemeldeten Familienmitglieder können Lesereisen entdecken und in Briefsequenzen mit Kuratoren-Notizen eintauchen. BLOG_WRITERs sehen zusätzlich Bearbeiten/Löschen-Aktionen.</p>
|
||||
<div class="fl">/geschichten · /geschichten/new · /geschichten/[id]</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ KONZEPT ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Konzept</div>
|
||||
<p class="prose">Eine <em>Lesereise</em> ist eine <code>Geschichte</code> mit <code>type === 'JOURNEY'</code>. Ihr Kerninhalt ist eine geordnete Sequenz von Briefen (<code>JourneyItem</code>s mit <code>document_id</code>) und Zwischentexten (<code>JourneyItem</code>s ohne <code>document_id</code>). Das optionale Feld <code>body</code> dient als Einleitung/Preface.</p>
|
||||
<p class="prose">Diese Spec deckt drei Änderungen ab: (1) die Typauswahl auf <code>/geschichten/new</code> als vorgelagerter Schritt, (2) das „REISE"-Badge in der Übersichtsliste, und (3) die neue Journey-Leseansicht auf der Detailseite, die den bestehenden Prosa-Body durch eine nummerierte Briefliste ersetzt.</p>
|
||||
<p class="prose">Dokument-Items zeigen Titel, Datum, Sender→Empfänger und einen Link zum Brief. Optionale Kuratoren-Notizen erscheinen als Annotation mit Mint-Linker-Rand unter dem Briefeintrag. Interlude-Items (kein Dokument) erscheinen als eingerückte Absätze mit orangenem linken Rand — klar vom Dokumenttyp unterscheidbar, aber harmonisch im Lesefluss.</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LR-0: TYPE SELECTOR ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Typauswahl</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LR-0 — Typauswahl /geschichten/new</h3>
|
||||
<span class="scr-id">Issue #752 · LR-0</span>
|
||||
</div>
|
||||
<p class="scr-desc">Neuer vorgelagerter Schritt beim Erstellen einer Geschichte. Zwei Karten zur Auswahl: „Geschichte" (Prosa) und „Lesereise" (Briefsequenz). Die ausgewählte Karte wird hervorgehoben. Erst nach Auswahl wird der „Weiter"-Button aktiv. Auswahl bleibt im URL-Param erhalten (<code>?type=JOURNEY</code>).</p>
|
||||
<p class="scr-var"><strong>Varianten:</strong> Keine Auswahl (Weiter-Button inaktiv) · Lesereise gewählt (hier gezeigt) · Geschichte gewählt</p>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||
<span class="bp-lbl">Desktop — 1040px · Lesereise gewählt</span>
|
||||
<div class="desk" style="min-height:320px;">
|
||||
<div class="fa-nav">
|
||||
<span class="fa-logo">ARCHIV</span>
|
||||
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||
<span class="fa-link">Dokumente</span>
|
||||
<span class="fa-link">Personen</span>
|
||||
<span class="fa-link active">Geschichten</span>
|
||||
<span class="fa-link">Chronik</span>
|
||||
<div class="fa-nav-r">
|
||||
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-topbar">
|
||||
<div class="ed-back">←</div>
|
||||
<div class="ed-title-label">Neue Geschichte</div>
|
||||
<div class="ed-status-pill ed-status-draft">ENTWURF</div>
|
||||
</div>
|
||||
<div class="type-selector">
|
||||
<div class="type-selector-inner">
|
||||
<div class="type-selector-q">Was möchtest du erstellen?</div>
|
||||
<div class="type-cards">
|
||||
<!-- Story card -->
|
||||
<div class="type-card">
|
||||
<div class="type-card-icon">✍️</div>
|
||||
<div class="type-card-title">Geschichte</div>
|
||||
<div class="type-card-desc">Freier Prosatext über Familienerlebnisse, Erinnerungen oder historische Einordnungen — mit verlinkten Personen und Dokumenten.</div>
|
||||
</div>
|
||||
<!-- Journey card (selected) -->
|
||||
<div class="type-card selected">
|
||||
<div class="type-card-icon">📜</div>
|
||||
<div class="type-card-title">Lesereise</div>
|
||||
<div class="type-card-desc">Geordnete Briefsequenz mit optionalen Kuratoren-Notizen zwischen den Briefen — für chronologische Korrespondenz-Sammlungen.</div>
|
||||
<div class="type-card-check">
|
||||
<svg viewBox="0 0 10 10" fill="none"><path d="M2 5l2.5 2.5L8 3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="type-next-bar">
|
||||
<button class="type-next-btn">
|
||||
Weiter
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LR-0 Typauswahl</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Layout</td></tr>
|
||||
<tr><td>Selector area</td><td>flex flex-1 items-center justify-center bg-canvas px-6 py-10</td><td>zentriert, füllt restliche Höhe</td></tr>
|
||||
<tr><td>Frage</td><td>font-serif text-sm text-ink-2 text-center mb-4</td><td></td></tr>
|
||||
<tr><td>Karten-Grid</td><td>flex gap-4</td><td>2 gleich breite Karten; auf Mobile flex-col</td></tr>
|
||||
<tr class="grp"><td colspan="3">Type-Karte</td></tr>
|
||||
<tr><td>Karte (inaktiv)</td><td>border border-line rounded-md p-4 bg-white cursor-pointer hover:border-primary hover:bg-surface</td><td>focus-visible:ring-2 focus-visible:ring-primary</td></tr>
|
||||
<tr><td>Karte (ausgewählt)</td><td>border-2 border-orange-500 bg-orange-50 shadow-sm</td><td>aria-pressed="true"; kein Tailwind-Kürzel — nutze CSS-var(--orange)</td></tr>
|
||||
<tr><td>Check-Kreis</td><td>w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center self-end mt-2</td><td>nur sichtbar wenn ausgewählt</td></tr>
|
||||
<tr><td>Kartentitel</td><td>font-serif text-sm text-ink</td><td></td></tr>
|
||||
<tr><td>Kartenbeschreibung</td><td>text-xs text-ink-3 leading-relaxed mt-1</td><td></td></tr>
|
||||
<tr class="grp"><td colspan="3">Navigation</td></tr>
|
||||
<tr><td>Weiter-Button</td><td>rounded border border-primary bg-primary text-white px-4 py-2 text-sm font-medium disabled:opacity-40</td><td>disabled wenn keine Karte ausgewählt</td></tr>
|
||||
<tr><td>URL-Param</td><td>?type=STORY | ?type=JOURNEY</td><td>per goto() nach Klick auf Weiter; lesefreundlich bookmarkbar</td></tr>
|
||||
<tr><td>Mobile</td><td>flex-col Karten; volle Breite</td><td>kein Scrollbedarf auf 320px</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LR-1: LIST WITH BADGE ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Übersichtsliste</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LR-1 — Reise-Badge in /geschichten</h3>
|
||||
<span class="scr-id">Issue #752 · LR-1</span>
|
||||
</div>
|
||||
<p class="scr-desc">Die Übersichtsliste erhält ein kleines „REISE"-Badge in der Metaspalte einer Journey-Zeile — unterhalb von Datum und Personenchip. Zeilen mit <code>type === 'STORY'</code> bleiben unverändert. Das Badge ist nicht klickbar, dient als reine visuelle Unterscheidung.</p>
|
||||
<p class="scr-var"><strong>Varianten:</strong> Mischte Liste (hier gezeigt) · Nur-Journey-Filter · Nur-Story-Ansicht (unverändert)</p>
|
||||
|
||||
<div class="previews">
|
||||
<!-- Desktop -->
|
||||
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||
<span class="bp-lbl">Desktop — 1040px · gemischte Liste</span>
|
||||
<div class="desk">
|
||||
<div class="fa-nav">
|
||||
<span class="fa-logo">ARCHIV</span>
|
||||
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||
<span class="fa-link">Dokumente</span>
|
||||
<span class="fa-link">Personen</span>
|
||||
<span class="fa-link active">Geschichten</span>
|
||||
<span class="fa-link">Chronik</span>
|
||||
<div class="fa-nav-r">
|
||||
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#E8E7E2;flex:1;padding:14px 16px;">
|
||||
<div class="g-page-hdr" style="padding:0 0 8px;">
|
||||
<span class="g-page-title">Geschichten</span>
|
||||
<button class="g-new-btn">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
Neue Geschichte
|
||||
</button>
|
||||
</div>
|
||||
<div class="g-list-card">
|
||||
<div class="g-filters">
|
||||
<span class="g-pill active">Alle</span>
|
||||
<span class="g-pill">Franz Raddatz</span>
|
||||
<span class="g-pill">Emma Müller</span>
|
||||
<span class="g-pill" style="border-style:dashed;color:#6B6A63;">+ Person wählen</span>
|
||||
</div>
|
||||
<!-- Row 1: Story (no badge) -->
|
||||
<div class="g-row">
|
||||
<div class="g-meta">
|
||||
<div class="g-av av-navy">MR</div>
|
||||
<div class="g-author">Maria Raddatz</div>
|
||||
<div class="g-date">14. März 2025</div>
|
||||
<span class="g-chip">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:#012851;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:var(--mint);flex-shrink:0;">FR</span>
|
||||
Franz Raddatz
|
||||
</span>
|
||||
</div>
|
||||
<div class="g-content">
|
||||
<div class="g-title">Der Sommer in Breslau</div>
|
||||
<div class="g-excerpt">Oma erzählte oft vom letzten Sommer vor dem Krieg, als die Familie noch vollständig zusammen war und niemand ahnte, was kommen würde…</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 2: Journey (badge!) -->
|
||||
<div class="g-row">
|
||||
<div class="g-meta">
|
||||
<div class="g-av av-purple">KR</div>
|
||||
<div class="g-author">Klaus Raddatz</div>
|
||||
<div class="g-date">15. Mai 2025</div>
|
||||
<span class="g-chip">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:#012851;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:var(--mint);flex-shrink:0;">FR</span>
|
||||
Franz Raddatz
|
||||
</span>
|
||||
<span class="j-badge">REISE</span>
|
||||
</div>
|
||||
<div class="g-content">
|
||||
<div class="g-title">Briefe aus Breslau 1938–1942</div>
|
||||
<div class="g-excerpt">Eine Lesereise durch den Briefwechsel zwischen Franz und Emma — von den letzten Friedenssommern bis zum Ende des Krieges.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 3: Story -->
|
||||
<div class="g-row">
|
||||
<div class="g-meta">
|
||||
<div class="g-av av-teal">GK</div>
|
||||
<div class="g-author">Gertrud Koch</div>
|
||||
<div class="g-date">18. Okt. 2024</div>
|
||||
<span class="g-chip">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:#534AB7;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:#fff;flex-shrink:0;">EM</span>
|
||||
Emma Müller
|
||||
</span>
|
||||
</div>
|
||||
<div class="g-content">
|
||||
<div class="g-title">Die Hochzeit im Krieg</div>
|
||||
<div class="g-excerpt">1943, mitten im Chaos — Emma bestand darauf, dass das Fest stattfand. Ihr Bruder kam auf Fronturlaub, drei Tage nur, aber es reichte…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="prev-col">
|
||||
<span class="bp-lbl">Mobile — 320px</span>
|
||||
<div class="phone">
|
||||
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||
<div class="pb">
|
||||
<div class="m-nav">
|
||||
<span class="m-logo">ARCHIV</span>
|
||||
<div class="m-nav-r">
|
||||
<div class="m-av">MR</div>
|
||||
<div class="m-ham"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#E8E7E2;flex:1;display:flex;flex-direction:column;">
|
||||
<div style="padding:8px 10px 4px;">
|
||||
<span style="font-family:Georgia,serif;font-size:13px;color:#012851;">Geschichten</span>
|
||||
</div>
|
||||
<div class="m-filters">
|
||||
<span class="g-pill active" style="font-size:6px;padding:2px 7px;">Alle</span>
|
||||
<span class="g-pill" style="font-size:6px;padding:2px 7px;">Franz Raddatz</span>
|
||||
<span class="g-pill" style="font-size:6px;padding:2px 7px;border-style:dashed;">+ Person…</span>
|
||||
</div>
|
||||
<div style="background:#fff;flex:1;">
|
||||
<!-- Story row -->
|
||||
<div class="m-row">
|
||||
<div class="m-row-top">
|
||||
<div class="g-av av-navy" style="width:16px;height:16px;font-size:5.5px;">MR</div>
|
||||
<span class="m-author-name">Maria Raddatz</span>
|
||||
<span class="m-date">14. Mrz. 2025</span>
|
||||
</div>
|
||||
<div class="m-title">Der Sommer in Breslau</div>
|
||||
<div class="m-excerpt">Oma erzählte oft vom letzten Sommer vor dem Krieg…</div>
|
||||
</div>
|
||||
<!-- Journey row (badge) -->
|
||||
<div class="m-row">
|
||||
<div class="m-row-top">
|
||||
<div class="g-av av-purple" style="width:16px;height:16px;font-size:5.5px;">KR</div>
|
||||
<span class="m-author-name">Klaus Raddatz</span>
|
||||
<span class="m-date">15. Mai 2025</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;margin-bottom:3px;">
|
||||
<div class="m-title" style="margin-bottom:0;">Briefe aus Breslau 1938–1942</div>
|
||||
<span class="j-badge" style="flex-shrink:0;">REISE</span>
|
||||
</div>
|
||||
<div class="m-excerpt">Eine Lesereise durch den Briefwechsel zwischen Franz und Emma…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LR-1 Journey-Badge in der Liste</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Badge</td></tr>
|
||||
<tr><td>Journey badge</td><td>inline-flex items-center px-1.5 py-px rounded-sm text-[10px] font-bold uppercase tracking-wide bg-orange-50 text-orange-700 border border-orange-200</td><td>nur wenn type === 'JOURNEY'</td></tr>
|
||||
<tr><td>Position Desktop</td><td>unterhalb Datum-Text und Personenchip in der Metaspalte (g-meta)</td><td>kein extra Abstand nötig — gap-1 der Flex-Spalte reicht</td></tr>
|
||||
<tr><td>Position Mobile</td><td>inline flex items-center gap-1.5 neben Titel</td><td>Titel + Badge in einem flex-Wrapper; badge shrink-0</td></tr>
|
||||
<tr><td>aria-label</td><td>aria-label="Lesereise"</td><td>Badge ist span, kein interaktives Element</td></tr>
|
||||
<tr class="grp"><td colspan="3">Bedingte Logik</td></tr>
|
||||
<tr><td>Svelte guard</td><td>{#if geschichte.type === 'JOURNEY'}<span …>REISE</span>{/if}</td><td>kein Badge für STORY</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LR-2: JOURNEY READER ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Journey-Leseansicht</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LR-2 — Journey-Detail /geschichten/[id]</h3>
|
||||
<span class="scr-id">Issue #752 · LR-2</span>
|
||||
</div>
|
||||
<p class="scr-desc">Wenn <code>type === 'JOURNEY'</code> ersetzt die geordnete Briefliste den Prosa-Body. Optional zeigt ein Einleitungsabsatz (<code>body</code>) vor den Items. Jedes Item ist entweder ein Briefeintrag (Kartentitel, Datum, Link) oder ein Interlude-Absatz (orangener linker Rand, kursiv). Die Reihenfolge ergibt sich von oben nach unten — keine Nummern. Briefeinträge können eine optionale Kuratoren-Annotation unter dem Link zeigen.</p>
|
||||
<p class="scr-var"><strong>Varianten:</strong> Leserin ohne Schreibrecht · BLOG_WRITER (Bearbeiten/Löschen sichtbar — hier gezeigt) · Mobile</p>
|
||||
|
||||
<div class="previews">
|
||||
<!-- Desktop -->
|
||||
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||
<span class="bp-lbl">Desktop — 1040px · BLOG_WRITER-Ansicht</span>
|
||||
<div class="desk" style="min-height:600px;">
|
||||
<div class="fa-nav">
|
||||
<span class="fa-logo">ARCHIV</span>
|
||||
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||
<span class="fa-link">Dokumente</span>
|
||||
<span class="fa-link">Personen</span>
|
||||
<span class="fa-link active">Geschichten</span>
|
||||
<span class="fa-link">Chronik</span>
|
||||
<div class="fa-nav-r">
|
||||
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#E8E7E2;flex:1;padding:16px 20px;">
|
||||
<div class="jr-article">
|
||||
<div class="jr-back">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M6 2L2 5l4 3" stroke="#6B6A63" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Zurück zu Geschichten
|
||||
</div>
|
||||
<div class="jr-badge">LESEREISE</div>
|
||||
<div class="jr-title">Briefe aus Breslau 1938–1942</div>
|
||||
<div class="jr-metabar">
|
||||
<div class="g-av av-purple" style="width:20px;height:20px;font-size:6.5px;">KR</div>
|
||||
<div>
|
||||
<div style="font-size:7.5px;font-weight:700;color:#1C1C18;line-height:1.2;">Klaus Raddatz</div>
|
||||
<div style="font-size:6.5px;color:#6B6A63;">zusammengestellt am 15. Mai 2025</div>
|
||||
</div>
|
||||
<div class="jr-metabar-r">
|
||||
<button class="jr-edit-btn">Bearbeiten</button>
|
||||
<span style="font-size:6.5px;font-weight:600;color:#DC4C3E;">Löschen</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Intro -->
|
||||
<div class="jr-intro">Der Briefwechsel zwischen Franz Raddatz und seiner Schwester Emma umspannt vier Jahre — von den letzten unbeschwerten Sommerwochen 1938 bis zum Kriegsende. Diese Lesereise folgt den Briefen in chronologischer Reihenfolge.</div>
|
||||
<!-- Item 1: Document, no annotation -->
|
||||
<div class="jr-item">
|
||||
<div class="jr-card">
|
||||
<div class="jr-card-title">Brief vom 12. Juli 1938</div>
|
||||
<div class="jr-card-meta">12. Juli 1938 · von Franz Raddatz an Emma Müller</div>
|
||||
<div class="jr-card-link">
|
||||
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
|
||||
Brief öffnen
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Interlude -->
|
||||
<div class="jr-interlude">
|
||||
<div class="jr-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde. Seine Briefe aus dieser Zeit tragen eine Leichtigkeit, die in den späteren Kriegsjahren vollständig verschwindet.</div>
|
||||
</div>
|
||||
<!-- Item 2: Document with annotation -->
|
||||
<div class="jr-item">
|
||||
<div class="jr-card">
|
||||
<div class="jr-card-title">Postkarte aus Breslau, August 1938</div>
|
||||
<div class="jr-card-meta">22. Aug. 1938 · von Franz Raddatz an Emma Müller</div>
|
||||
<div class="jr-card-link">
|
||||
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
|
||||
Brief öffnen
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
<div class="jr-annotation">
|
||||
<div class="jr-annotation-text">Diese Karte ist ungewöhnlich kurz für Franz — vier Zeilen, fast hastig. Ein Zeichen der aufkommenden Unruhe in den Nachrichten, oder schlicht die Hitze des Augusts?</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Item 3: Document -->
|
||||
<div class="jr-item">
|
||||
<div class="jr-card">
|
||||
<div class="jr-card-title">Brief vom 3. September 1939</div>
|
||||
<div class="jr-card-meta">3. Sept. 1939 · von Emma Müller an Franz Raddatz</div>
|
||||
<div class="jr-card-link">
|
||||
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
|
||||
Brief öffnen
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="prev-col">
|
||||
<span class="bp-lbl">Mobile — 320px · Leserin</span>
|
||||
<div class="phone" style="min-height:520px;">
|
||||
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||
<div class="pb">
|
||||
<div class="m-nav">
|
||||
<span class="m-logo">ARCHIV</span>
|
||||
<div class="m-nav-r">
|
||||
<div class="m-av">MR</div>
|
||||
<div class="m-ham"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#E8E7E2;flex:1;padding:10px;">
|
||||
<div class="mjr-article">
|
||||
<div class="mjr-back">
|
||||
<svg width="6" height="6" viewBox="0 0 10 10" fill="none"><path d="M6 2L2 5l4 3" stroke="#6B6A63" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Zurück
|
||||
</div>
|
||||
<div class="mjr-badge">LESEREISE</div>
|
||||
<div class="mjr-title">Briefe aus Breslau 1938–1942</div>
|
||||
<div class="mjr-metabar">
|
||||
<div class="g-av av-purple" style="width:16px;height:16px;font-size:5.5px;flex-shrink:0;">KR</div>
|
||||
<div>
|
||||
<div style="font-size:7px;font-weight:700;color:#1C1C18;">Klaus Raddatz</div>
|
||||
<div style="font-size:6px;color:#6B6A63;">15. Mai 2025</div>
|
||||
</div>
|
||||
<div style="margin-left:auto;font-size:12px;color:#6B6A63;">···</div>
|
||||
</div>
|
||||
<div style="height:1px;background:#EDECEA;margin-bottom:8px;"></div>
|
||||
<div class="mjr-intro">Der Briefwechsel zwischen Franz und Emma — von 1938 bis Kriegsende.</div>
|
||||
<!-- Item 1 -->
|
||||
<div class="mjr-item">
|
||||
<div class="mjr-card">
|
||||
<div class="mjr-card-title">Brief vom 12. Juli 1938</div>
|
||||
<div class="mjr-card-meta">12. Juli 1938 · Franz → Emma</div>
|
||||
<div class="mjr-card-link">Brief öffnen →</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Interlude -->
|
||||
<div class="mjr-interlude">
|
||||
<div class="mjr-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde.</div>
|
||||
</div>
|
||||
<!-- Item 2 -->
|
||||
<div class="mjr-item">
|
||||
<div class="mjr-card">
|
||||
<div class="mjr-card-title">Postkarte Aug. 1938</div>
|
||||
<div class="mjr-card-meta">22. Aug. 1938 · Franz → Emma</div>
|
||||
<div class="mjr-card-link">Brief öffnen →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LR-2 Journey-Leseansicht</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
|
||||
<tr><td>Bedingte Logik</td><td>{#if geschichte.type === 'JOURNEY'} JourneyReader {:else} StoryReader {/if}</td><td>in +page.svelte von /geschichten/[id]</td></tr>
|
||||
<tr><td>Artikel-Container</td><td>max-w-3xl mx-auto px-4 py-8</td><td>gleich wie StoryReader</td></tr>
|
||||
<tr><td>Journey-Badge</td><td>inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-orange-50 text-orange-700 border border-orange-200 mb-2</td><td>über dem Titel; nicht für STORY</td></tr>
|
||||
<tr><td>Titel</td><td>font-serif text-3xl text-ink leading-tight mb-4</td><td>gleich wie Story</td></tr>
|
||||
<tr><td>Metabar</td><td>flex items-center gap-3 pb-4 border-b border-subtle mb-4</td><td>gleich wie Story</td></tr>
|
||||
<tr><td>Bearbeiten/Löschen</td><td>nur BLOG_WRITE; auf Mobile im ··· BottomSheet</td><td>gleich wie Story</td></tr>
|
||||
<tr class="grp"><td colspan="3">Intro-Absatz</td></tr>
|
||||
<tr><td>Intro (body)</td><td>font-serif text-sm text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle</td><td>nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext</td></tr>
|
||||
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
|
||||
<tr><td>Item-Zeile</td><td>mb-3</td><td>kein flex nötig — Karte ist full-width</td></tr>
|
||||
<tr><td>Dokumentkarte</td><td>bg-white border border-line rounded-sm p-3</td><td></td></tr>
|
||||
<tr><td>Brieftitel</td><td>font-serif text-sm text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
||||
<tr><td>Briefmeta</td><td>text-xs text-ink-3 mb-2</td><td>formatDate(document.documentDate) · "von X an Y"</td></tr>
|
||||
<tr><td>Brief öffnen Link</td><td>inline-flex items-center gap-1 text-xs font-semibold text-ink hover:text-primary</td><td>href="/documents/{item.document.id}"</td></tr>
|
||||
<tr class="grp"><td colspan="3">Kuratoren-Annotation</td></tr>
|
||||
<tr><td>Annotation</td><td>mt-3 pl-3 border-l-2 border-mint bg-surface rounded-r-sm py-1.5 pr-2</td><td>nur rendern wenn item.note vorhanden</td></tr>
|
||||
<tr><td>Annotations-Text</td><td>text-xs italic text-ink-2 leading-relaxed</td><td></td></tr>
|
||||
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
|
||||
<tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-orange-400 bg-orange-50 rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr>
|
||||
<tr><td>Interlude-Text</td><td>text-xs italic text-ink leading-relaxed</td><td>item.note; plaintext</td></tr>
|
||||
<tr class="grp"><td colspan="3">Mobile</td></tr>
|
||||
<tr><td>··· Menü</td><td>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</td><td>BLOG_WRITE; gleich wie Story</td></tr>
|
||||
<tr><td>Touch Target (Brief öffnen)</td><td>min-h-[44px] durch padding auf der Karte</td><td>WCAG 2.2 AA</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||
<div class="llm">
|
||||
<h2>Implementation Guide — Lesereisen Reader</h2>
|
||||
|
||||
<h3>Geänderte Views und Routen</h3>
|
||||
<table>
|
||||
<thead><tr><th>View</th><th>Route</th><th>Änderung</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Neue Geschichte</td><td>/geschichten/new</td><td>Neuer Typauswahl-Schritt als first render; setzt ?type=STORY|JOURNEY</td></tr>
|
||||
<tr><td>Geschichten-Liste</td><td>/geschichten</td><td>Journey-Badge in GeschichtenCard wenn type === 'JOURNEY'</td></tr>
|
||||
<tr><td>Geschichte-Detail</td><td>/geschichten/[id]</td><td>Bedingte Verzweigung: JourneyReader | StoryReader</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Neue Komponenten</h3>
|
||||
<ul>
|
||||
<li><code>JourneyReader.svelte</code> — rendert Intro + Items-Liste; Props: <code>geschichte: GeschichteDetail</code></li>
|
||||
<li><code>JourneyItemCard.svelte</code> — ein Dokument-Item mit optionaler Annotation; Props: <code>item: JourneyItem, position: number</code></li>
|
||||
<li><code>JourneyInterlude.svelte</code> — ein reiner Text-Interlude; Props: <code>note: string</code></li>
|
||||
</ul>
|
||||
|
||||
<h3>Datenmodell (nach #750)</h3>
|
||||
<ul>
|
||||
<li><code>GeschichteType: 'STORY' | 'JOURNEY'</code></li>
|
||||
<li><code>JourneyItem: { id: UUID, position: number, document: DocumentSummary | null, note: string | null }</code></li>
|
||||
<li><code>Geschichte.items</code> — geordnete Liste (nach <code>position</code> ASC); für STORY leer</li>
|
||||
<li><code>Geschichte.body</code> — für JOURNEY der optionale Einleitungstext (plaintext, kein HTML); für STORY der Rich-Text-Body</li>
|
||||
</ul>
|
||||
|
||||
<h3>Typauswahl — Implementierungshinweise</h3>
|
||||
<ul>
|
||||
<li>Die Typauswahl ist ein Schritt INNERHALB der <code>/geschichten/new</code>-Route — kein eigener URL, kein <code>goto()</code>. Zustand <code>let selectedType: GeschichteType | null = null</code> in der Komponente.</li>
|
||||
<li>Erst wenn <code>selectedType !== null</code> ist der „Weiter"-Button aktiviert (<code>disabled={!selectedType}</code>).</li>
|
||||
<li>Nach Klick auf „Weiter": wenn <code>selectedType === 'JOURNEY'</code> → <code>goto('/geschichten/new?type=JOURNEY')</code> und zeige den Journey-Editor (aus Issue #753); wenn <code>STORY</code> → bestehender GeschichteEditor (unverändert).</li>
|
||||
<li>Die Karten verwenden <code>role="radio"</code> und <code>aria-checked</code> für Accessibility. Keyboard: Arrow-Keys wechseln zwischen den Karten, Space/Enter wählt aus.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Journey-Badge — Implementierungshinweise</h3>
|
||||
<ul>
|
||||
<li>Badge nur in <code>GeschichtenCard.svelte</code> hinzufügen — keine Änderung an der Listenlogik oder dem API-Aufruf.</li>
|
||||
<li>Text: „REISE" (Kurzform für die Metaspalte); <code>aria-label="Lesereise"</code> für den Badge-Span.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Journey-Reader — Implementierungshinweise</h3>
|
||||
<ul>
|
||||
<li>Items werden bereits geordnet vom Backend geliefert (<code>ORDER BY position ASC</code>). Keine client-seitige Sortierung nötig.</li>
|
||||
<li>Ein Item ist Interlude wenn <code>item.document === null</code>. In diesem Fall: <code>JourneyInterlude</code>-Komponente rendern.</li>
|
||||
<li>Der Intro-Absatz (<code>body</code>) wird als Plaintext gerendert — <em>nicht</em> als innerHTML. Im Editor wird es als einfaches Textarea gespeichert, kein HTML.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Berechtigungen</h3>
|
||||
<ul>
|
||||
<li>„Bearbeiten" und „Löschen" nur für <code>currentUser.permissions.includes('BLOG_WRITE')</code> — gleich wie Story.</li>
|
||||
<li>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Barrierefreiheit</h3>
|
||||
<ul>
|
||||
<li>Items-Liste: <code><ol></code> semantisch für die geordnete Briefliste. Interludes sind <code><li></code>-Elemente mit <code>aria-label="Kuratorennotiz"</code>.</li>
|
||||
<li>„Brief öffnen"-Link: beschreibender Text mit Briefdatum im <code>aria-label</code>, z.B. <code>aria-label="Brief vom 12. Juli 1938 öffnen"</code>.</li>
|
||||
<li>Touch-Targets: jede Dokumentkarte hat mindestens 44px Höhe durch den Padding der Karte.</li>
|
||||
<li>Fokusring: <code>focus-visible:ring-2 focus-visible:ring-primary</code> auf allen Links.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1257
docs/superpowers/plans/2026-06-07-spacy-nlp-service.md
Normal file
1257
docs/superpowers/plans/2026-06-07-spacy-nlp-service.md
Normal file
File diff suppressed because it is too large
Load Diff
188
docs/superpowers/specs/2026-06-07-spacy-nlp-service-design.md
Normal file
188
docs/superpowers/specs/2026-06-07-spacy-nlp-service-design.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# spaCy NLP Service — Design Spec
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Status:** Prototype
|
||||
|
||||
## Problem
|
||||
|
||||
The current NL search uses Ollama (`qwen2.5:7b-instruct-q4_K_M`) to parse free-text queries into structured extractions (person names, dates, role, keywords). Inference takes 5–15 seconds per query, making the feature too slow to be useful compared to filling in the filter UI manually.
|
||||
|
||||
## Goal
|
||||
|
||||
Build a standalone `nlp-service/` prototype that replaces Ollama with spaCy for query parsing. The prototype is scoped to **extraction quality evaluation** — run it locally, curl it with real archive queries, and measure whether spaCy extracts names/dates/keywords well enough to justify a full migration. No Java-side changes in this iteration.
|
||||
|
||||
## Extraction Contract
|
||||
|
||||
The service must produce an output compatible with the existing `OllamaExtraction` Java record:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `personNames` | `string[]` | Names of persons mentioned, left-to-right order |
|
||||
| `personRole` | `"sender"` \| `"receiver"` \| `"any"` | Role of the person(s) in the document |
|
||||
| `dateFrom` | `string \| null` | ISO 8601 date `YYYY-MM-DD` or null |
|
||||
| `dateTo` | `string \| null` | ISO 8601 date `YYYY-MM-DD` or null |
|
||||
| `keywords` | `string[]` | Content words — fuzzy-matched against tags by Java |
|
||||
| `rawQuery` | `string` | Echo of the input query |
|
||||
|
||||
**Two-person ordering:** `personNames` must be in left-to-right span order. Java maps `[0]` → sender, `[1]` → receiver.
|
||||
|
||||
**`rawQuery` note:** In the current Java code `rawQuery` is set by the caller, not parsed from Ollama. The service echoes the input for convenience; the eventual `RestClientSpacyClient` will set it from the input directly, same as today.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
nlp-service/
|
||||
├── main.py # FastAPI app — /parse and /health endpoints
|
||||
├── extractor.py # NLP pipeline: NER → role → dates → keywords
|
||||
├── models.py # Pydantic request/response types
|
||||
├── requirements.txt
|
||||
├── Dockerfile
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
Sits alongside `ocr-service/` in the repo. For the prototype it runs standalone (no docker-compose wiring).
|
||||
|
||||
## Extraction Pipeline (`extractor.py`)
|
||||
|
||||
Five steps run in sequence on each query.
|
||||
|
||||
### Step 1 — NER pass
|
||||
|
||||
Run spaCy on the query using the model for the requested language. Collect:
|
||||
- All `PER` spans → candidates for `personNames`
|
||||
- All `DATE` spans → raw text strings for step 3
|
||||
|
||||
### Step 2 — Role detection
|
||||
|
||||
Only relevant when exactly **one** PER entity is found. Walk the dependency tree of the PER span's root token; check if a governing `case` or `prep` token matches the sender or receiver preposition set for the language:
|
||||
|
||||
| Language | Sender prepositions | Receiver prepositions |
|
||||
|---|---|---|
|
||||
| `de` | von, vom | an, nach, für |
|
||||
| `en` | from, by | to, for |
|
||||
| `es` | de, por | para, a |
|
||||
|
||||
- One person + sender preposition → `personRole = "sender"`
|
||||
- One person + receiver preposition → `personRole = "receiver"`
|
||||
- One person + no match / two or more persons → `personRole = "any"`
|
||||
|
||||
Two-person queries always return `"any"` — Java derives direction from position.
|
||||
|
||||
### Step 3 — Date parsing
|
||||
|
||||
For each DATE span, inspect the token immediately before the span to detect range direction:
|
||||
|
||||
| Direction token | Effect |
|
||||
|---|---|
|
||||
| vor / before / antes de | Span → `dateTo` |
|
||||
| nach / after / después de | Span → `dateFrom` |
|
||||
| zwischen…und / between…and / entre…y | Earlier span → `dateFrom`, later → `dateTo` |
|
||||
| No direction token (bare year/date) | Span → both `dateFrom` and `dateTo` set to that year (year-range, Jan 1–Dec 31) |
|
||||
|
||||
`dateparser.parse()` with `PREFER_DAY_OF_MONTH=first` converts the span text to a Python `date`. For `dateTo` results that resolve to a year boundary, set to Dec 31 of that year (mirrors `RestClientOllamaClient.parseDate()` behaviour).
|
||||
|
||||
Output as ISO strings (`YYYY-MM-DD`) or `null`.
|
||||
|
||||
### Step 4 — Keyword extraction
|
||||
|
||||
Collect tokens that satisfy all of:
|
||||
- POS tag is `NOUN` or `PROPN`
|
||||
- Not a stopword
|
||||
- Not inside any NER span (PER or DATE)
|
||||
- Lemma length ≥ 3
|
||||
|
||||
Output as lowercased lemmas. These are fuzzy-matched against the tags table by `NlQueryParserService.resolveTags()` on the Java side — no tag lookup in the Python service.
|
||||
|
||||
Examples:
|
||||
- "Briefe aus dem Krieg" → `keywords: ["brief", "krieg"]`
|
||||
- "Texte über Weihnachten" → `keywords: ["text", "weihnachten"]`
|
||||
|
||||
### Step 5 — Assembly
|
||||
|
||||
```json
|
||||
{
|
||||
"personNames": ["Opa Hermann", "Marie"],
|
||||
"personRole": "any",
|
||||
"dateFrom": null,
|
||||
"dateTo": "1920-12-31",
|
||||
"keywords": ["brief"],
|
||||
"rawQuery": "Briefe von Opa Hermann an Marie vor 1920"
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `POST /parse`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "query": "Briefe von Opa Hermann an Marie vor 1920", "lang": "de" }
|
||||
```
|
||||
|
||||
`lang` is a required enum: `"de"` | `"en"` | `"es"`. Unknown values → HTTP 422 (FastAPI validation).
|
||||
|
||||
**Response:** extraction object as above, HTTP 200.
|
||||
|
||||
**Error:** pipeline crash → HTTP 500 `{"detail": "..."}`.
|
||||
|
||||
### `GET /health`
|
||||
|
||||
Returns HTTP 200 `{"status": "ok"}` when all three models are loaded.
|
||||
|
||||
## Language Models
|
||||
|
||||
| `lang` | spaCy model |
|
||||
|---|---|
|
||||
| `de` | `de_core_news_sm` |
|
||||
| `en` | `en_core_web_sm` |
|
||||
| `es` | `es_core_news_sm` |
|
||||
|
||||
All three models are loaded at startup and held in memory. Routing is by the `lang` field on the request.
|
||||
|
||||
## Dockerfile
|
||||
|
||||
Mirrors `ocr-service/` — `python:3.11-slim`, non-root user, models baked into the image:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN python -m spacy download de_core_news_sm \
|
||||
&& python -m spacy download en_core_web_sm \
|
||||
&& python -m spacy download es_core_news_sm
|
||||
COPY . .
|
||||
RUN useradd --no-create-home --shell /usr/sbin/nologin --uid 1001 nlp \
|
||||
&& chown -R nlp:nlp /app
|
||||
USER nlp
|
||||
EXPOSE 8001
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
```
|
||||
|
||||
Image size: ~350 MB. No volume needed — models live in the image layer.
|
||||
|
||||
## Local Dev
|
||||
|
||||
```bash
|
||||
cd nlp-service
|
||||
pip install -r requirements.txt
|
||||
python -m spacy download de_core_news_sm en_core_web_sm es_core_news_sm
|
||||
uvicorn main:app --reload --port 8001
|
||||
|
||||
curl -X POST http://localhost:8001/parse \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "Briefe von Opa Hermann an Marie vor 1920", "lang": "de"}'
|
||||
```
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **Historical names:** spaCy models are trained on modern news corpora. Unusual 1899–1950 German names may not score as `PER`. Mitigation: the Java `resolveNames()` already does fuzzy matching against the persons table, so partial name extraction is recoverable.
|
||||
- **Role detection:** the preposition sets are a fixed enumeration (~12 tokens across 3 languages). Sentences that express direction without one of these prepositions will fall through to `personRole = "any"`. This is acceptable — `"any"` is the safe default and searches both sender and receiver positions.
|
||||
- **"über Oma" ambiguity:** if spaCy recognises "Oma" as a PER entity it lands in `personNames` (person search); if not, it lands in `keywords` (tag search via Java). Both paths return relevant results. The prototype evaluation will reveal which path dominates for real archive queries.
|
||||
|
||||
## Out of Scope (prototype)
|
||||
|
||||
- docker-compose integration (Ollama replacement)
|
||||
- Java-side changes (`RestClientSpacyClient`, rename `OllamaClient` → `NlParserClient`)
|
||||
- Tag lookup inside the Python service
|
||||
- Automated test suite (pytest fixtures) — evaluation is done by curling the running service
|
||||
@@ -28,7 +28,6 @@ src/
|
||||
│ ├── +layout.server.ts # Loads current user, injects auth cookie
|
||||
│ ├── +page.svelte # Home / document search dashboard
|
||||
│ ├── documents/ # Document CRUD, detail, edit, upload
|
||||
│ ├── search/ # Smart (NL) search sub-components — SmartModeToggle, InterpretationChipRow, SmartSearchStatus, DisambiguationPicker (no +page; consumed by documents/ and SearchFilterBar)
|
||||
│ ├── persons/ # Person directory (filtered, paginated), detail, edit, merge, review (triage)
|
||||
│ ├── aktivitaeten/ # Unified activity feed (Chronik)
|
||||
│ ├── admin/ # User, group, tag, OCR, system management
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// NL search is mocked at the network boundary — Ollama is not required in CI.
|
||||
// CSRF enforcement is bypassed by page.route (the real request is never sent),
|
||||
// so it is only verified in manual full-stack runs (see issue #739 DevOps notes).
|
||||
const interpretation = {
|
||||
resolvedPersons: [
|
||||
{ id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter Raddatz' },
|
||||
{ id: '22222222-2222-2222-2222-222222222222', displayName: 'Emma Raddatz' }
|
||||
],
|
||||
ambiguousPersons: [],
|
||||
dateFrom: '1914-01-01',
|
||||
dateTo: '1918-12-31',
|
||||
keywords: ['krieg'],
|
||||
resolvedTags: [{ id: '33333333-3333-3333-3333-333333333333', name: 'Weltkrieg', color: 'sage' }],
|
||||
rawQuery: 'Was hat Walter an Emma im Krieg geschrieben?',
|
||||
keywordsApplied: true,
|
||||
tagsApplied: true
|
||||
};
|
||||
|
||||
const nlResponse = {
|
||||
result: {
|
||||
items: [],
|
||||
totalElements: 0,
|
||||
pageNumber: 0,
|
||||
pageSize: 20,
|
||||
totalPages: 0,
|
||||
undatedCount: 0
|
||||
},
|
||||
interpretation
|
||||
};
|
||||
|
||||
test.describe('NL (smart) search — happy path', () => {
|
||||
test('toggle → loading → chips → remove chip re-runs keyword search; axe clean light + dark', async ({
|
||||
page
|
||||
}) => {
|
||||
// Deliberate delay so the loading state is assertable before the response arrives.
|
||||
await page.route('**/api/search/nl', async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(nlResponse)
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/documents');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Switch to smart mode via the toggle pill (keyword label = "Text").
|
||||
await page.getByRole('button', { name: /Text/ }).click();
|
||||
|
||||
const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…');
|
||||
await input.fill('Was hat Walter an Emma im Krieg geschrieben?');
|
||||
await input.press('Enter');
|
||||
|
||||
// Loading panel announced to screen readers.
|
||||
await expect(page.getByText(/Archiv wird befragt/)).toBeVisible();
|
||||
|
||||
// Directional chip (Walter → Emma) + keyword chip + theme chip render once the fixture resolves.
|
||||
await expect(page.getByText('→')).toBeVisible();
|
||||
await expect(page.getByText('Stichwort: krieg')).toBeVisible();
|
||||
await expect(page.getByText(/Thema:.*Weltkrieg/)).toBeVisible();
|
||||
|
||||
// Accessibility — light mode.
|
||||
const lightScan = await new AxeBuilder({ page })
|
||||
.include('[data-testid="smart-search-results"]')
|
||||
.analyze();
|
||||
expect(lightScan.violations).toEqual([]);
|
||||
|
||||
// Accessibility — dark mode.
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||
const darkScan = await new AxeBuilder({ page })
|
||||
.include('[data-testid="smart-search-results"]')
|
||||
.analyze();
|
||||
expect(darkScan.violations).toEqual([]);
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light'));
|
||||
|
||||
// Removing the keyword chip re-runs a keyword GET with the remaining resolved
|
||||
// params (sender + receiver from the directional pair).
|
||||
await page.getByRole('button', { name: 'Filter entfernen: Stichwort: krieg' }).click();
|
||||
await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/);
|
||||
await expect(page).toHaveURL(/receiverId=22222222-2222-2222-2222-222222222222/);
|
||||
});
|
||||
|
||||
test('removing the last theme chip drops tag/tagOp but keeps person params', async ({ page }) => {
|
||||
await page.route('**/api/search/nl', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(nlResponse)
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/documents');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByRole('button', { name: /Text/ }).click();
|
||||
|
||||
const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…');
|
||||
await input.fill('Was hat Walter an Emma im Krieg geschrieben?');
|
||||
await input.press('Enter');
|
||||
|
||||
await expect(page.getByText(/Thema:.*Weltkrieg/)).toBeVisible();
|
||||
|
||||
// Remove the single theme chip — URL must carry sender UUID but no tag/tagOp.
|
||||
await page.getByRole('button', { name: 'Filter entfernen: Thema: Weltkrieg' }).click();
|
||||
await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/);
|
||||
const url = page.url();
|
||||
expect(url).not.toMatch(/tag=/);
|
||||
expect(url).not.toMatch(/tagOp=/);
|
||||
});
|
||||
});
|
||||
@@ -22,33 +22,6 @@
|
||||
"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.",
|
||||
"search_toggle_smart_label": "KI",
|
||||
"search_toggle_smart_label_suffix": "-Suche",
|
||||
"search_toggle_keyword_label": "Text",
|
||||
"search_toggle_keyword_label_suffix": "suche",
|
||||
"search_loading_nl": "Archiv wird befragt…",
|
||||
"search_loading_nl_sub": "Die KI analysiert Ihre Anfrage. Das kann bis zu 15 Sekunden dauern.",
|
||||
"search_error_unavailable": "Intelligente Suche nicht verfügbar",
|
||||
"search_error_unavailable_body": "Die KI-Suche ist momentan nicht erreichbar. Sie können Ihre Anfrage als einfache Volltextsuche wiederholen.",
|
||||
"search_switch_to_keyword": "Zur Volltextsuche wechseln",
|
||||
"search_error_rate_limited": "Zu viele Anfragen",
|
||||
"search_error_rate_limited_body": "Sie haben die intelligente Suche zu häufig genutzt. Bitte warten Sie eine Minute und versuchen Sie es erneut.",
|
||||
"search_empty_nl": "Keine Ergebnisse",
|
||||
"search_empty_retry_keyword": "Als Volltextsuche wiederholen",
|
||||
"search_filter_remove_label": "Filter entfernen: {label}",
|
||||
"search_chip_sender": "Absender",
|
||||
"search_chip_date": "Zeitraum",
|
||||
"search_chip_keyword": "Stichwort",
|
||||
"search_chip_theme_prefix": "Thema",
|
||||
"search_chip_directional_label": "Von {from} zu {to}, Filter entfernen",
|
||||
"search_disambiguation_trigger_label": "Mehrere Personen gefunden — zum Auswählen klicken",
|
||||
"search_disambiguation_cue": "(auswählen…)",
|
||||
"search_disambiguation_heading": "Person auswählen",
|
||||
"search_disambiguation_did_you_mean": "Meintest du {name}?",
|
||||
"search_disambiguation_select_label": "{name} auswählen",
|
||||
"error_validation_error": "Die Eingabe ist ungültig.",
|
||||
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||
"nav_documents": "Dokumente",
|
||||
|
||||
@@ -22,33 +22,6 @@
|
||||
"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.",
|
||||
"search_toggle_smart_label": "AI",
|
||||
"search_toggle_smart_label_suffix": " search",
|
||||
"search_toggle_keyword_label": "Text",
|
||||
"search_toggle_keyword_label_suffix": " search",
|
||||
"search_loading_nl": "Querying the archive…",
|
||||
"search_loading_nl_sub": "The AI is analysing your request. This can take up to 15 seconds.",
|
||||
"search_error_unavailable": "Smart search unavailable",
|
||||
"search_error_unavailable_body": "The AI search is currently unreachable. You can repeat your request as a plain full-text search.",
|
||||
"search_switch_to_keyword": "Switch to full-text search",
|
||||
"search_error_rate_limited": "Too many requests",
|
||||
"search_error_rate_limited_body": "You have used the smart search too frequently. Please wait a minute and try again.",
|
||||
"search_empty_nl": "No results",
|
||||
"search_empty_retry_keyword": "Repeat as full-text search",
|
||||
"search_filter_remove_label": "Remove filter: {label}",
|
||||
"search_chip_sender": "Sender",
|
||||
"search_chip_date": "Period",
|
||||
"search_chip_keyword": "Keyword",
|
||||
"search_chip_theme_prefix": "Topic",
|
||||
"search_chip_directional_label": "From {from} to {to}, remove filter",
|
||||
"search_disambiguation_trigger_label": "Several people found — click to choose",
|
||||
"search_disambiguation_cue": "(choose…)",
|
||||
"search_disambiguation_heading": "Choose a person",
|
||||
"search_disambiguation_did_you_mean": "Did you mean {name}?",
|
||||
"search_disambiguation_select_label": "Select {name}",
|
||||
"error_validation_error": "The input is invalid.",
|
||||
"error_internal_error": "An unexpected error occurred.",
|
||||
"nav_documents": "Documents",
|
||||
|
||||
@@ -22,33 +22,6 @@
|
||||
"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.",
|
||||
"search_toggle_smart_label": "IA",
|
||||
"search_toggle_smart_label_suffix": " búsqueda",
|
||||
"search_toggle_keyword_label": "Texto",
|
||||
"search_toggle_keyword_label_suffix": " búsqueda",
|
||||
"search_loading_nl": "Consultando el archivo…",
|
||||
"search_loading_nl_sub": "La IA está analizando su solicitud. Esto puede tardar hasta 15 segundos.",
|
||||
"search_error_unavailable": "Búsqueda inteligente no disponible",
|
||||
"search_error_unavailable_body": "La búsqueda con IA no está disponible en este momento. Puede repetir su solicitud como una búsqueda de texto completo.",
|
||||
"search_switch_to_keyword": "Cambiar a búsqueda de texto completo",
|
||||
"search_error_rate_limited": "Demasiadas solicitudes",
|
||||
"search_error_rate_limited_body": "Ha utilizado la búsqueda inteligente con demasiada frecuencia. Espere un minuto e inténtelo de nuevo.",
|
||||
"search_empty_nl": "Sin resultados",
|
||||
"search_empty_retry_keyword": "Repetir como búsqueda de texto completo",
|
||||
"search_filter_remove_label": "Eliminar filtro: {label}",
|
||||
"search_chip_sender": "Remitente",
|
||||
"search_chip_date": "Período",
|
||||
"search_chip_keyword": "Palabra clave",
|
||||
"search_chip_theme_prefix": "Tema",
|
||||
"search_chip_directional_label": "De {from} a {to}, eliminar filtro",
|
||||
"search_disambiguation_trigger_label": "Se encontraron varias personas — haga clic para elegir",
|
||||
"search_disambiguation_cue": "(elegir…)",
|
||||
"search_disambiguation_heading": "Elegir una persona",
|
||||
"search_disambiguation_did_you_mean": "¿Quería decir {name}?",
|
||||
"search_disambiguation_select_label": "Seleccionar {name}",
|
||||
"error_validation_error": "La entrada no es válida.",
|
||||
"error_internal_error": "Se ha producido un error inesperado.",
|
||||
"nav_documents": "Documentos",
|
||||
|
||||
@@ -228,22 +228,6 @@ 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;
|
||||
@@ -1835,9 +1819,6 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
targetId: string;
|
||||
};
|
||||
NlSearchRequest: {
|
||||
query: string;
|
||||
};
|
||||
Pageable: {
|
||||
/** Format: int32 */
|
||||
page?: number;
|
||||
@@ -1897,34 +1878,6 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
length: number;
|
||||
};
|
||||
NlQueryInterpretation: {
|
||||
resolvedPersons: components["schemas"]["PersonHint"][];
|
||||
ambiguousPersons: components["schemas"]["PersonHint"][];
|
||||
/** Format: date */
|
||||
dateFrom?: string;
|
||||
/** Format: date */
|
||||
dateTo?: string;
|
||||
keywords: string[];
|
||||
resolvedTags: components["schemas"]["TagHint"][];
|
||||
rawQuery: string;
|
||||
keywordsApplied: boolean;
|
||||
tagsApplied: boolean;
|
||||
};
|
||||
NlSearchResponse: {
|
||||
result: components["schemas"]["DocumentSearchResult"];
|
||||
interpretation: components["schemas"]["NlQueryInterpretation"];
|
||||
};
|
||||
PersonHint: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
displayName: string;
|
||||
};
|
||||
TagHint: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
};
|
||||
SearchMatchData: {
|
||||
transcriptionSnippet?: string;
|
||||
titleOffsets: components["schemas"]["MatchOffset"][];
|
||||
@@ -3244,32 +3197,6 @@ 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?: {
|
||||
|
||||
@@ -53,8 +53,6 @@ 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'
|
||||
@@ -180,10 +178,6 @@ 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':
|
||||
|
||||
@@ -3,7 +3,6 @@ import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
||||
import TagInput from '$lib/tag/TagInput.svelte';
|
||||
import DateInput from '$lib/shared/primitives/DateInput.svelte';
|
||||
import SortDropdown from '$lib/shared/primitives/SortDropdown.svelte';
|
||||
import SmartModeToggle from './search/SmartModeToggle.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
@@ -21,15 +20,12 @@ let {
|
||||
sort = $bindable('DATE'),
|
||||
dir = $bindable('desc'),
|
||||
showAdvanced = $bindable(false),
|
||||
smartMode = $bindable(false),
|
||||
initialSenderName = '',
|
||||
initialReceiverName = '',
|
||||
navKey = 0,
|
||||
isLoading = false,
|
||||
onSearch,
|
||||
onSearchImmediate,
|
||||
onSmartSearch,
|
||||
onModeToggle,
|
||||
onfocus,
|
||||
onblur
|
||||
}: {
|
||||
@@ -46,28 +42,16 @@ let {
|
||||
sort?: string;
|
||||
dir?: string;
|
||||
showAdvanced?: boolean;
|
||||
smartMode?: boolean;
|
||||
initialSenderName?: string;
|
||||
initialReceiverName?: string;
|
||||
navKey?: number;
|
||||
isLoading?: boolean;
|
||||
onSearch: () => void;
|
||||
onSearchImmediate?: () => void;
|
||||
onSmartSearch?: () => void;
|
||||
onModeToggle?: () => void;
|
||||
onfocus?: () => void;
|
||||
onblur?: () => void;
|
||||
} = $props();
|
||||
|
||||
// In smart mode the keyword search must not fire on every keystroke — the NL
|
||||
// query is submitted only on Enter (or an explicit button click).
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
if (smartMode && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onSmartSearch?.();
|
||||
}
|
||||
}
|
||||
|
||||
// Plain (non-reactive) flag — not $state, so no reactive assignment inside $effect
|
||||
let sortDirMounted = false;
|
||||
|
||||
@@ -92,19 +76,13 @@ $effect(() => {
|
||||
<input
|
||||
type="text"
|
||||
bind:value={q}
|
||||
oninput={smartMode ? undefined : onSearch}
|
||||
onkeydown={onSearchKeydown}
|
||||
oninput={onSearch}
|
||||
onfocus={onfocus}
|
||||
onblur={onblur}
|
||||
maxlength={smartMode ? 500 : undefined}
|
||||
aria-label={m.docs_search_placeholder()}
|
||||
placeholder={m.docs_search_placeholder()}
|
||||
class="block w-full border-line py-2.5 pl-10 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {smartMode
|
||||
? 'pr-28'
|
||||
: 'pr-20'}"
|
||||
class="block w-full border-line py-2.5 pr-4 pl-10 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<!-- Decorative search icon / loading spinner — left slot keeps the right
|
||||
slot free for the always-visible smart-mode toggle pill. -->
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
{#if isLoading}
|
||||
<svg
|
||||
@@ -132,7 +110,6 @@ $effect(() => {
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<SmartModeToggle bind:smartMode={smartMode} onToggle={onModeToggle} />
|
||||
</div>
|
||||
|
||||
<!-- Sort Dropdown -->
|
||||
|
||||
@@ -195,39 +195,3 @@ describe('SearchFilterBar – tagQ live filter', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchFilterBar – smart-mode chip lifecycle hooks', () => {
|
||||
// The interpretation chips live in the result area (parent page). SearchFilterBar
|
||||
// drives chip-clearing through callbacks: onModeToggle (mode switch) and
|
||||
// onSmartSearch (new query). These tests pin that contract.
|
||||
it('invokes onModeToggle when toggling back to keyword mode (parent clears chips)', async () => {
|
||||
const onModeToggle = vi.fn();
|
||||
render(SearchFilterBar, {
|
||||
...defaultProps,
|
||||
sort: 'DATE',
|
||||
dir: 'desc',
|
||||
smartMode: true,
|
||||
onModeToggle
|
||||
});
|
||||
await page.getByRole('button', { name: /KI/ }).click();
|
||||
expect(onModeToggle).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('invokes onSmartSearch when a new query is submitted in smart mode (parent resets chips)', async () => {
|
||||
const onSmartSearch = vi.fn();
|
||||
render(SearchFilterBar, {
|
||||
...defaultProps,
|
||||
sort: 'DATE',
|
||||
dir: 'desc',
|
||||
smartMode: true,
|
||||
onSmartSearch
|
||||
});
|
||||
const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…');
|
||||
await input.fill('Walter im Krieg');
|
||||
await input.click();
|
||||
(document.activeElement as HTMLElement).dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
|
||||
);
|
||||
await vi.waitFor(() => expect(onSmartSearch).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,22 +8,9 @@ import DocumentList from '../DocumentList.svelte';
|
||||
import Pagination from '$lib/shared/primitives/Pagination.svelte';
|
||||
import BulkSelectionBar from '$lib/document/BulkSelectionBar.svelte';
|
||||
import TimelineDensityFilter from '$lib/document/TimelineDensityFilter.svelte';
|
||||
import SmartSearchStatus from '../search/SmartSearchStatus.svelte';
|
||||
import InterpretationChipRow from '../search/InterpretationChipRow.svelte';
|
||||
import type { ChipType } from '../search/chip-types.js';
|
||||
import { buildThemeRemovalUrl } from './theme-chip-removal.js';
|
||||
import DisambiguationPicker from '../search/DisambiguationPicker.svelte';
|
||||
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
|
||||
import { getErrorMessage, parseBackendError } from '$lib/shared/errors';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||
type NlSearchResponse = components['schemas']['NlSearchResponse'];
|
||||
type DocumentSearchResult = components['schemas']['DocumentSearchResult'];
|
||||
type PersonHint = components['schemas']['PersonHint'];
|
||||
type SmartSearchErrorCode = 'SMART_SEARCH_UNAVAILABLE' | 'SMART_SEARCH_RATE_LIMITED';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -47,27 +34,6 @@ let tagQ = $state(untrack(() => data.tagQ || ''));
|
||||
let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND'));
|
||||
let undated = $state(untrack(() => data.undated ?? false));
|
||||
|
||||
// Smart (NL) search — UI-local state, resets on real page navigation (away + back).
|
||||
let smartMode = $state(false);
|
||||
let nlSubmitted = $state(false);
|
||||
let nlLoading = $state(false);
|
||||
let nlError = $state<SmartSearchErrorCode | null>(null);
|
||||
let nlInterpretation = $state<NlQueryInterpretation | null>(null);
|
||||
let nlResult = $state<DocumentSearchResult | null>(null);
|
||||
|
||||
const showNlView = $derived(smartMode && nlSubmitted);
|
||||
const nlHasResults = $derived((nlResult?.items.length ?? 0) > 0);
|
||||
const ambiguousPersons = $derived(nlInterpretation?.ambiguousPersons ?? []);
|
||||
const nlIsAmbiguous = $derived(ambiguousPersons.length > 0);
|
||||
// A 1-item picker is always a "did you mean …?" suggestion (a single direct match auto-selects
|
||||
// and never reaches the picker); ≥2 keeps the "choose a person" framing and the action cue.
|
||||
const disambiguationHeading = $derived(
|
||||
ambiguousPersons.length === 1
|
||||
? m.search_disambiguation_did_you_mean({ name: ambiguousPersons[0].displayName })
|
||||
: m.search_disambiguation_heading()
|
||||
);
|
||||
const showDisambiguationCue = $derived(ambiguousPersons.length >= 2);
|
||||
|
||||
function hasAdvancedFilters() {
|
||||
return (
|
||||
(data.tags?.length ?? 0) > 0 ||
|
||||
@@ -198,124 +164,6 @@ function handleImmediateSearch() {
|
||||
triggerSearchKeepZoom();
|
||||
}
|
||||
|
||||
function resetNlState() {
|
||||
nlSubmitted = false;
|
||||
nlLoading = false;
|
||||
nlError = null;
|
||||
nlInterpretation = null;
|
||||
nlResult = null;
|
||||
}
|
||||
|
||||
/** Toggling the mode (either direction) always clears any prior NL interpretation. */
|
||||
function onModeToggle() {
|
||||
resetNlState();
|
||||
}
|
||||
|
||||
/** Submit the natural-language query to the server-side parser. */
|
||||
async function runSmartSearch() {
|
||||
const query = q.trim();
|
||||
if (query.length < 3) return;
|
||||
nlSubmitted = true;
|
||||
nlLoading = true;
|
||||
nlError = null;
|
||||
nlInterpretation = null;
|
||||
nlResult = null;
|
||||
try {
|
||||
const res = await csrfFetch('/api/search/nl', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const backend = await parseBackendError(res);
|
||||
nlError =
|
||||
backend?.code === 'SMART_SEARCH_RATE_LIMITED'
|
||||
? 'SMART_SEARCH_RATE_LIMITED'
|
||||
: 'SMART_SEARCH_UNAVAILABLE';
|
||||
return;
|
||||
}
|
||||
const body: NlSearchResponse = await res.json();
|
||||
nlInterpretation = body.interpretation;
|
||||
nlResult = body.result;
|
||||
} catch {
|
||||
nlError = 'SMART_SEARCH_UNAVAILABLE';
|
||||
} finally {
|
||||
nlLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Option A empty/error fallback: drop NL mode, keep the raw query, run a keyword search. */
|
||||
function switchToKeywordMode() {
|
||||
resetNlState();
|
||||
smartMode = false;
|
||||
handleImmediateSearch();
|
||||
}
|
||||
|
||||
/** Applies a resolved param set to the keyword filters and re-runs via GET. */
|
||||
function applyResolvedAndSearch(p: {
|
||||
senderId: string;
|
||||
receiverId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
q: string;
|
||||
}) {
|
||||
resetNlState();
|
||||
smartMode = false;
|
||||
senderId = p.senderId;
|
||||
receiverId = p.receiverId;
|
||||
from = p.from;
|
||||
to = p.to;
|
||||
q = p.q;
|
||||
handleImmediateSearch();
|
||||
}
|
||||
|
||||
function paramsFromInterpretation(interp: NlQueryInterpretation) {
|
||||
const resolved = interp.resolvedPersons;
|
||||
return {
|
||||
senderId: resolved.length >= 1 ? resolved[0].id : '',
|
||||
receiverId: resolved.length >= 2 ? resolved[1].id : '',
|
||||
from: interp.dateFrom ?? '',
|
||||
to: interp.dateTo ?? '',
|
||||
q: interp.keywordsApplied ? interp.keywords.join(' ') : ''
|
||||
};
|
||||
}
|
||||
|
||||
function removeChip(type: ChipType, value?: string) {
|
||||
if (!nlInterpretation) return;
|
||||
const p = paramsFromInterpretation(nlInterpretation);
|
||||
if (type === 'sender') {
|
||||
p.senderId = '';
|
||||
} else if (type === 'directional') {
|
||||
p.senderId = '';
|
||||
p.receiverId = '';
|
||||
} else if (type === 'date') {
|
||||
p.from = '';
|
||||
p.to = '';
|
||||
} else if (type === 'keyword' && value) {
|
||||
const remaining = nlInterpretation.keywords.filter((keyword) => keyword !== value);
|
||||
p.q = remaining.join(' ');
|
||||
} else if (type === 'theme' && value) {
|
||||
const url = buildThemeRemovalUrl(nlInterpretation, value);
|
||||
resetNlState();
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
return;
|
||||
}
|
||||
applyResolvedAndSearch(p);
|
||||
}
|
||||
|
||||
/** Single-select disambiguation: resolved person becomes sender, chosen becomes receiver. */
|
||||
function selectDisambiguated(person: PersonHint) {
|
||||
if (!nlInterpretation) return;
|
||||
const resolved = nlInterpretation.resolvedPersons;
|
||||
applyResolvedAndSearch({
|
||||
senderId: resolved.length >= 1 ? resolved[0].id : person.id,
|
||||
receiverId: resolved.length >= 1 ? person.id : '',
|
||||
from: nlInterpretation.dateFrom ?? '',
|
||||
to: nlInterpretation.dateTo ?? '',
|
||||
q: nlInterpretation.keywordsApplied ? nlInterpretation.keywords.join(' ') : ''
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger search reactively when the tag list changes.
|
||||
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
|
||||
$effect(() => {
|
||||
@@ -420,7 +268,6 @@ $effect(() => {
|
||||
bind:tagQ={tagQ}
|
||||
bind:tagOperator={tagOperator}
|
||||
bind:undated={undated}
|
||||
bind:smartMode={smartMode}
|
||||
undatedCount={data.undatedCount ?? 0}
|
||||
initialSenderName={initialSenderName}
|
||||
initialReceiverName={initialReceiverName}
|
||||
@@ -428,71 +275,20 @@ $effect(() => {
|
||||
isLoading={navigating.to !== null}
|
||||
onSearch={handleTextSearch}
|
||||
onSearchImmediate={handleImmediateSearch}
|
||||
onSmartSearch={runSmartSearch}
|
||||
onModeToggle={onModeToggle}
|
||||
onfocus={() => (qFocused = true)}
|
||||
onblur={() => (qFocused = false)}
|
||||
/>
|
||||
|
||||
{#if showNlView}
|
||||
<!-- Smart-search results area: loading / error / chips + results / empty / disambiguation. -->
|
||||
<div data-testid="smart-search-results">
|
||||
{#if nlLoading}
|
||||
<SmartSearchStatus status="loading" />
|
||||
{:else if nlError}
|
||||
<SmartSearchStatus
|
||||
status="error"
|
||||
errorCode={nlError}
|
||||
onSwitchToKeyword={switchToKeywordMode}
|
||||
/>
|
||||
{:else if nlInterpretation}
|
||||
{#key nlInterpretation}
|
||||
<div class="mb-4">
|
||||
{#if nlIsAmbiguous}
|
||||
<DisambiguationPicker
|
||||
persons={nlInterpretation.ambiguousPersons}
|
||||
heading={disambiguationHeading}
|
||||
showCue={showDisambiguationCue}
|
||||
onSelect={selectDisambiguated}
|
||||
/>
|
||||
{:else}
|
||||
<InterpretationChipRow interpretation={nlInterpretation} onRemoveChip={removeChip} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !nlIsAmbiguous}
|
||||
{#if nlHasResults}
|
||||
<p class="mb-3 font-sans text-base text-ink-2">
|
||||
{m.docs_result_count({ count: nlResult?.totalElements ?? 0 })}
|
||||
</p>
|
||||
<DocumentList items={nlResult?.items ?? []} canWrite={data.canWrite} sort={sort} />
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<p class="text-sm font-bold text-ink">{m.search_empty_nl()}</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={switchToKeywordMode}
|
||||
class="inline-flex min-h-[44px] items-center rounded px-3 py-2 text-sm font-bold text-primary underline underline-offset-4 outline-none hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
{m.search_empty_retry_keyword()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-3 mb-4 hidden lg:block">
|
||||
<TimelineDensityFilter
|
||||
density={data.density}
|
||||
minDate={data.minDate}
|
||||
maxDate={data.maxDate}
|
||||
zoomFrom={data.zoomFrom}
|
||||
zoomTo={data.zoomTo}
|
||||
from={from}
|
||||
to={to}
|
||||
onchange={(event) => {
|
||||
<div class="mt-3 mb-4 hidden lg:block">
|
||||
<TimelineDensityFilter
|
||||
density={data.density}
|
||||
minDate={data.minDate}
|
||||
maxDate={data.maxDate}
|
||||
zoomFrom={data.zoomFrom}
|
||||
zoomTo={data.zoomTo}
|
||||
from={from}
|
||||
to={to}
|
||||
onchange={(event) => {
|
||||
from = event.from;
|
||||
to = event.to;
|
||||
// Drag commits filter + zoom atomically (Graylog-style range selector).
|
||||
@@ -503,70 +299,69 @@ $effect(() => {
|
||||
triggerSearchKeepZoom();
|
||||
}
|
||||
}}
|
||||
onzoomchange={(event) => {
|
||||
onzoomchange={(event) => {
|
||||
triggerSearchWithZoom(event?.zoomFrom ?? null, event?.zoomTo ?? null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex items-center justify-between gap-4">
|
||||
<p class="font-sans text-base text-ink-2">
|
||||
{#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if}
|
||||
</p>
|
||||
{#if data.canWrite}
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<div class="flex items-center gap-4">
|
||||
{#if data.totalElements > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={editAllMatching}
|
||||
disabled={editingAll}
|
||||
class="inline-flex cursor-pointer items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
|
||||
data-testid="bulk-edit-all-x"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.bulk_edit_all_x({ count: data.totalElements })}
|
||||
</button>
|
||||
{/if}
|
||||
<a
|
||||
href="/documents/new"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
<div class="mb-3 flex items-center justify-between gap-4">
|
||||
<p class="font-sans text-base text-ink-2">
|
||||
{#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if}
|
||||
</p>
|
||||
{#if data.canWrite}
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<div class="flex items-center gap-4">
|
||||
{#if data.totalElements > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={editAllMatching}
|
||||
disabled={editingAll}
|
||||
class="inline-flex cursor-pointer items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
|
||||
data-testid="bulk-edit-all-x"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.docs_btn_new()}
|
||||
</a>
|
||||
</div>
|
||||
{#if editAllError}
|
||||
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
|
||||
{editAllError}
|
||||
</p>
|
||||
{m.bulk_edit_all_x({ count: data.totalElements })}
|
||||
</button>
|
||||
{/if}
|
||||
<a
|
||||
href="/documents/new"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.docs_btn_new()}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if editAllError}
|
||||
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
|
||||
{editAllError}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<DocumentList
|
||||
items={data.items}
|
||||
q={data.q}
|
||||
canWrite={data.canWrite}
|
||||
error={data.error}
|
||||
sort={sort}
|
||||
from={data.from}
|
||||
to={data.to}
|
||||
/>
|
||||
<DocumentList
|
||||
items={data.items}
|
||||
q={data.q}
|
||||
canWrite={data.canWrite}
|
||||
error={data.error}
|
||||
sort={sort}
|
||||
from={data.from}
|
||||
to={data.to}
|
||||
/>
|
||||
|
||||
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
||||
{/if}
|
||||
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
||||
</main>
|
||||
|
||||
<BulkSelectionBar canWrite={data.canWrite} />
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildThemeRemovalUrl } from './theme-chip-removal.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||
|
||||
function makeInterp(overrides: Partial<NlQueryInterpretation> = {}): NlQueryInterpretation {
|
||||
return {
|
||||
resolvedPersons: [],
|
||||
ambiguousPersons: [],
|
||||
keywords: [],
|
||||
resolvedTags: [],
|
||||
rawQuery: '',
|
||||
keywordsApplied: false,
|
||||
tagsApplied: true,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function makeTag(id: string, name: string, color?: string) {
|
||||
return color ? { id, name, color } : { id, name };
|
||||
}
|
||||
|
||||
describe('buildThemeRemovalUrl', () => {
|
||||
it('N remaining tags → N tag params + tagOp=OR', () => {
|
||||
const interp = makeInterp({
|
||||
resolvedTags: [
|
||||
makeTag('aaa', 'Hochzeit'),
|
||||
makeTag('bbb', 'Weltkrieg'),
|
||||
makeTag('ccc', 'Familie')
|
||||
]
|
||||
});
|
||||
const url = buildThemeRemovalUrl(interp, 'Hochzeit');
|
||||
const params = new URL(url, 'http://x').searchParams;
|
||||
expect(params.getAll('tag')).toEqual(['Weltkrieg', 'Familie']);
|
||||
expect(params.get('tagOp')).toBe('OR');
|
||||
});
|
||||
|
||||
it('last tag removed → no tag or tagOp params in URL', () => {
|
||||
const interp = makeInterp({
|
||||
resolvedTags: [makeTag('aaa', 'Hochzeit')]
|
||||
});
|
||||
const url = buildThemeRemovalUrl(interp, 'Hochzeit');
|
||||
const params = new URL(url, 'http://x').searchParams;
|
||||
expect(params.getAll('tag')).toEqual([]);
|
||||
expect(params.get('tagOp')).toBeNull();
|
||||
});
|
||||
|
||||
it('last tag removed with resolved sender person → sender param intact', () => {
|
||||
const interp = makeInterp({
|
||||
resolvedPersons: [{ id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter' }],
|
||||
resolvedTags: [makeTag('aaa', 'Hochzeit')]
|
||||
});
|
||||
const url = buildThemeRemovalUrl(interp, 'Hochzeit');
|
||||
const params = new URL(url, 'http://x').searchParams;
|
||||
expect(params.get('senderId')).toBe('11111111-1111-1111-1111-111111111111');
|
||||
expect(params.getAll('tag')).toEqual([]);
|
||||
expect(params.get('tagOp')).toBeNull();
|
||||
});
|
||||
|
||||
it('null-color tag → tag name emitted correctly; color does not affect params', () => {
|
||||
const interp = makeInterp({
|
||||
resolvedTags: [makeTag('aaa', 'Erbschaft'), makeTag('bbb', 'Migration')]
|
||||
});
|
||||
const url = buildThemeRemovalUrl(interp, 'Erbschaft');
|
||||
const params = new URL(url, 'http://x').searchParams;
|
||||
expect(params.getAll('tag')).toEqual(['Migration']);
|
||||
expect(params.get('tagOp')).toBe('OR');
|
||||
});
|
||||
|
||||
it('directional pair → senderId and receiverId both emitted', () => {
|
||||
const interp = makeInterp({
|
||||
resolvedPersons: [
|
||||
{ id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter' },
|
||||
{ id: '22222222-2222-2222-2222-222222222222', displayName: 'Emma' }
|
||||
],
|
||||
resolvedTags: [makeTag('aaa', 'Krieg'), makeTag('bbb', 'Heimat')]
|
||||
});
|
||||
const url = buildThemeRemovalUrl(interp, 'Krieg');
|
||||
const params = new URL(url, 'http://x').searchParams;
|
||||
expect(params.get('senderId')).toBe('11111111-1111-1111-1111-111111111111');
|
||||
expect(params.get('receiverId')).toBe('22222222-2222-2222-2222-222222222222');
|
||||
expect(params.getAll('tag')).toEqual(['Heimat']);
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||
|
||||
export function buildThemeRemovalUrl(
|
||||
interp: NlQueryInterpretation,
|
||||
removedTagName: string
|
||||
): string {
|
||||
const remaining = interp.resolvedTags.filter((t) => t.name !== removedTagName);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const resolved = interp.resolvedPersons;
|
||||
if (resolved.length >= 1) params.set('senderId', resolved[0].id);
|
||||
if (resolved.length >= 2) params.set('receiverId', resolved[1].id);
|
||||
if (interp.dateFrom) params.set('from', interp.dateFrom);
|
||||
if (interp.dateTo) params.set('to', interp.dateTo);
|
||||
if (interp.keywordsApplied && interp.keywords.length > 0) {
|
||||
params.set('q', interp.keywords.join(' '));
|
||||
}
|
||||
|
||||
remaining.forEach((tag) => params.append('tag', tag.name));
|
||||
if (remaining.length > 0) params.set('tagOp', 'OR');
|
||||
|
||||
const qs = params.toString();
|
||||
return qs ? `/documents?${qs}` : '/documents';
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type PersonHint = components['schemas']['PersonHint'];
|
||||
|
||||
let {
|
||||
persons,
|
||||
heading,
|
||||
showCue,
|
||||
onSelect
|
||||
}: {
|
||||
persons: PersonHint[];
|
||||
heading: string;
|
||||
showCue: boolean;
|
||||
onSelect: (person: PersonHint) => void;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let triggerEl = $state<HTMLButtonElement>();
|
||||
let listEl = $state<HTMLUListElement>();
|
||||
|
||||
const panelId = 'disambiguation-panel';
|
||||
const headingId = 'disambiguation-heading';
|
||||
const names = $derived(persons.map((person) => person.displayName).join(', '));
|
||||
const triggerLabel = $derived(
|
||||
persons.length === 1 ? heading : m.search_disambiguation_trigger_label()
|
||||
);
|
||||
|
||||
async function openPicker() {
|
||||
open = true;
|
||||
await tick();
|
||||
listEl?.querySelector<HTMLButtonElement>('button')?.focus();
|
||||
}
|
||||
|
||||
function closePicker() {
|
||||
open = false;
|
||||
triggerEl?.focus();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (open) closePicker();
|
||||
else openPicker();
|
||||
}
|
||||
|
||||
function select(person: PersonHint) {
|
||||
open = false;
|
||||
onSelect(person);
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.stopPropagation();
|
||||
closePicker();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="relative inline-block" use:clickOutside onclickoutside={() => open && closePicker()}>
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
type="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
aria-label={triggerLabel}
|
||||
onclick={toggle}
|
||||
class="inline-flex min-h-[44px] items-center gap-1.5 rounded-full border border-line bg-muted px-3 text-sm text-ink-2 outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
<span class="max-w-[8rem] truncate sm:max-w-[12rem]">{names}</span>
|
||||
{#if showCue}
|
||||
<span class="text-ink-3">{m.search_disambiguation_cue()}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
id={panelId}
|
||||
class="absolute left-0 z-10 mt-1 min-w-[12rem] rounded-sm border border-line bg-surface py-1 shadow-md"
|
||||
>
|
||||
<p id={headingId} class="px-4 py-1.5 text-sm font-bold text-ink">{heading}</p>
|
||||
<ul bind:this={listEl} aria-labelledby={headingId}>
|
||||
{#each persons as person (person.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.search_disambiguation_select_label({ name: person.displayName })}
|
||||
onclick={() => select(person)}
|
||||
class="flex min-h-[44px] w-full items-center px-4 text-left text-sm text-ink outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
{person.displayName}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,118 +0,0 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DisambiguationPicker from './DisambiguationPicker.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type PersonHint = components['schemas']['PersonHint'];
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const persons: PersonHint[] = [
|
||||
{ id: 'w1', displayName: 'Walter Raddatz' },
|
||||
{ id: 'w2', displayName: 'Walter Müller' }
|
||||
];
|
||||
|
||||
const multiProps = { persons, heading: 'Person auswählen', showCue: true };
|
||||
|
||||
function pressEscape() {
|
||||
(document.activeElement as HTMLElement).dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
|
||||
);
|
||||
}
|
||||
|
||||
describe('DisambiguationPicker', () => {
|
||||
it('opens the picker and shows a select option per ambiguous person', async () => {
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
|
||||
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
||||
.toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Walter Müller auswählen' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('moves focus into the picker list on open', async () => {
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
|
||||
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
||||
.toHaveFocus();
|
||||
});
|
||||
|
||||
it('returns focus to the trigger when closed with Escape', async () => {
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
|
||||
const trigger = page.getByRole('button', { name: /Mehrere Personen gefunden/ });
|
||||
await trigger.click();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
||||
.toHaveFocus();
|
||||
pressEscape();
|
||||
await expect.element(trigger).toHaveFocus();
|
||||
});
|
||||
|
||||
it('does not call onSelect when dismissed without choosing', async () => {
|
||||
const onSelect = vi.fn();
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect });
|
||||
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
||||
.toHaveFocus();
|
||||
pressEscape();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSelect with the chosen person', async () => {
|
||||
const onSelect = vi.fn();
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect });
|
||||
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||
await page.getByRole('button', { name: 'Walter Müller auswählen' }).click();
|
||||
expect(onSelect).toHaveBeenCalledWith(persons[1]);
|
||||
});
|
||||
|
||||
it('renders the supplied heading as a visible panel heading', async () => {
|
||||
render(DisambiguationPicker, {
|
||||
persons: [{ id: 'c1', displayName: 'Clara Cramer' }],
|
||||
heading: 'Meintest du Clara Cramer?',
|
||||
showCue: false,
|
||||
onSelect: vi.fn()
|
||||
});
|
||||
await page.getByRole('button', { name: 'Meintest du Clara Cramer?' }).click();
|
||||
await expect.element(page.getByText('Meintest du Clara Cramer?')).toBeVisible();
|
||||
});
|
||||
|
||||
it('suppresses the cue when showCue is false', async () => {
|
||||
render(DisambiguationPicker, {
|
||||
persons: [{ id: 'c1', displayName: 'Clara Cramer' }],
|
||||
heading: 'Meintest du Clara Cramer?',
|
||||
showCue: false,
|
||||
onSelect: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText('(auswählen…)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the cue when showCue is true', async () => {
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
|
||||
await expect.element(page.getByText('(auswählen…)')).toBeVisible();
|
||||
});
|
||||
|
||||
it('announces the did-you-mean heading as the trigger accessible name for a single suggestion', async () => {
|
||||
render(DisambiguationPicker, {
|
||||
persons: [{ id: 'c1', displayName: 'Clara Cramer' }],
|
||||
heading: 'Meintest du Clara Cramer?',
|
||||
showCue: false,
|
||||
onSelect: vi.fn()
|
||||
});
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Meintest du Clara Cramer?' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the multiple-people trigger accessible name for two or more suggestions', async () => {
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Mehrere Personen gefunden/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
import type { ChipType } from './chip-types.js';
|
||||
|
||||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||
type TagHint = components['schemas']['TagHint'];
|
||||
|
||||
let {
|
||||
interpretation,
|
||||
onRemoveChip
|
||||
}: {
|
||||
interpretation: NlQueryInterpretation;
|
||||
onRemoveChip: (type: ChipType, value?: string) => void;
|
||||
} = $props();
|
||||
|
||||
type Chip =
|
||||
| { key: string; type: 'sender'; label: string }
|
||||
| { key: string; type: 'directional'; from: string; to: string }
|
||||
| { key: string; type: 'date'; label: string }
|
||||
| { key: string; type: 'keyword'; value: string; label: string }
|
||||
| { key: string; type: 'theme'; tag: TagHint; label: string };
|
||||
|
||||
// Locally removed chips. The parent remounts this component (via {#key}) on every
|
||||
// new NL search, so this set never needs an explicit reset.
|
||||
const removed = new SvelteSet<string>();
|
||||
|
||||
function yearOf(iso: string | undefined): string | undefined {
|
||||
return iso?.slice(0, 4);
|
||||
}
|
||||
|
||||
function dateRangeLabel(from: string | undefined, to: string | undefined): string {
|
||||
const fromYear = yearOf(from);
|
||||
const toYear = yearOf(to);
|
||||
if (fromYear && toYear) return fromYear === toYear ? fromYear : `${fromYear}–${toYear}`;
|
||||
return fromYear ?? toYear ?? '';
|
||||
}
|
||||
|
||||
function tagColorStyle(color: string | undefined): string | undefined {
|
||||
if (!color) return undefined;
|
||||
return `background-color: var(--c-tag-${color}); border-left-color: var(--c-tag-${color})`;
|
||||
}
|
||||
|
||||
const chips = $derived.by(() => {
|
||||
const list: Chip[] = [];
|
||||
const {
|
||||
resolvedPersons,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
keywords,
|
||||
keywordsApplied,
|
||||
resolvedTags,
|
||||
tagsApplied
|
||||
} = interpretation;
|
||||
|
||||
if (resolvedPersons.length >= 2) {
|
||||
list.push({
|
||||
key: 'directional',
|
||||
type: 'directional',
|
||||
from: resolvedPersons[0].displayName,
|
||||
to: resolvedPersons[1].displayName
|
||||
});
|
||||
} else if (resolvedPersons.length === 1) {
|
||||
list.push({
|
||||
key: 'sender:' + resolvedPersons[0].id,
|
||||
type: 'sender',
|
||||
label: `${m.search_chip_sender()}: ${resolvedPersons[0].displayName}`
|
||||
});
|
||||
}
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
list.push({
|
||||
key: 'date',
|
||||
type: 'date',
|
||||
label: `${m.search_chip_date()}: ${dateRangeLabel(dateFrom, dateTo)}`
|
||||
});
|
||||
}
|
||||
|
||||
if (keywordsApplied) {
|
||||
for (const keyword of keywords) {
|
||||
list.push({
|
||||
key: 'keyword:' + keyword,
|
||||
type: 'keyword',
|
||||
value: keyword,
|
||||
label: `${m.search_chip_keyword()}: ${keyword}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (tagsApplied) {
|
||||
for (const tag of resolvedTags) {
|
||||
list.push({
|
||||
key: 'theme:' + tag.id,
|
||||
type: 'theme',
|
||||
tag,
|
||||
label: `${m.search_chip_theme_prefix()}: ${tag.name}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return list.filter((chip) => !removed.has(chip.key));
|
||||
});
|
||||
|
||||
const showKeywordsNotApplied = $derived(
|
||||
!interpretation.keywordsApplied && interpretation.keywords.length > 0
|
||||
);
|
||||
|
||||
function remove(chip: Chip) {
|
||||
removed.add(chip.key);
|
||||
if (chip.type === 'keyword') {
|
||||
onRemoveChip(chip.type, chip.value);
|
||||
} else if (chip.type === 'theme') {
|
||||
onRemoveChip(chip.type, chip.tag.name);
|
||||
} else {
|
||||
onRemoveChip(chip.type, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const nameSpan = 'sm:max-w-[12rem] max-w-[8rem] truncate';
|
||||
const chipWrapper =
|
||||
'inline-flex items-center gap-1.5 rounded-full border border-line bg-muted px-3 text-sm text-ink-2 focus-within:ring-2 focus-within:ring-brand-navy';
|
||||
const removeButton =
|
||||
'flex min-h-[44px] w-6 shrink-0 items-center justify-center text-ink-3 outline-none hover:text-red-500 focus-visible:ring-2 focus-visible:ring-brand-navy';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each chips as chip (chip.key)}
|
||||
{#if chip.type === 'directional'}
|
||||
<span
|
||||
data-chip-type="directional"
|
||||
class={chipWrapper}
|
||||
aria-label={m.search_chip_directional_label({ from: chip.from, to: chip.to })}
|
||||
>
|
||||
<span class={nameSpan}>{chip.from}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
<span class={nameSpan}>{chip.to}</span>
|
||||
<button
|
||||
type="button"
|
||||
class={removeButton}
|
||||
aria-label={m.search_filter_remove_label({ label: `${chip.from} → ${chip.to}` })}
|
||||
onclick={() => remove(chip)}
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</span>
|
||||
{:else if chip.type === 'theme'}
|
||||
<span data-chip-type="theme" class={chipWrapper} style={tagColorStyle(chip.tag.color)}>
|
||||
<span>{m.search_chip_theme_prefix()}:</span>
|
||||
<span class={nameSpan}>{chip.tag.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
class={removeButton}
|
||||
aria-label={m.search_filter_remove_label({
|
||||
label: `${m.search_chip_theme_prefix()}: ${chip.tag.name}`
|
||||
})}
|
||||
onclick={() => remove(chip)}
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</span>
|
||||
{:else}
|
||||
<span data-chip-type={chip.type} class={chipWrapper}>
|
||||
<span class={nameSpan}>{chip.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
class={removeButton}
|
||||
aria-label={m.search_filter_remove_label({ label: chip.label })}
|
||||
onclick={() => remove(chip)}
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if showKeywordsNotApplied}
|
||||
<p class="mt-2 text-xs text-ink-3">{m.smart_search_keywords_not_applied()}</p>
|
||||
{/if}
|
||||
@@ -1,214 +0,0 @@
|
||||
// NOTE: vitest-browser fails silently when the project path contains '+' (common in git worktrees
|
||||
// named 'feat+issue-NNN-slug'). If tests fail with iframe routing errors, copy the frontend
|
||||
// directory to a path without '+' (e.g. /tmp/fe-copy) and run the suite from there.
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import InterpretationChipRow from './InterpretationChipRow.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||
type PersonHint = components['schemas']['PersonHint'];
|
||||
type TagHint = components['schemas']['TagHint'];
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const makePerson = (id: string, displayName: string): PersonHint => ({ id, displayName });
|
||||
|
||||
const makeInterpretation = (
|
||||
overrides: Partial<NlQueryInterpretation> = {}
|
||||
): NlQueryInterpretation => ({
|
||||
resolvedPersons: [],
|
||||
ambiguousPersons: [],
|
||||
keywords: [],
|
||||
resolvedTags: [],
|
||||
rawQuery: 'test',
|
||||
keywordsApplied: true,
|
||||
tagsApplied: false,
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('InterpretationChipRow', () => {
|
||||
it('renders type-prefixed labels for sender, date and keyword chips', async () => {
|
||||
render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedPersons: [makePerson('p1', 'Walter Raddatz')],
|
||||
dateFrom: '1914-01-01',
|
||||
dateTo: '1918-12-31',
|
||||
keywords: ['krieg']
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText('Absender: Walter Raddatz')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Zeitraum: 1914–1918')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Stichwort: krieg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRemoveChip with "sender" when the sender chip × is clicked', async () => {
|
||||
const onRemoveChip = vi.fn();
|
||||
render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedPersons: [makePerson('p1', 'Walter Raddatz')]
|
||||
}),
|
||||
onRemoveChip
|
||||
});
|
||||
await page.getByRole('button', { name: /Absender: Walter Raddatz/ }).click();
|
||||
expect(onRemoveChip).toHaveBeenCalledWith('sender', undefined);
|
||||
});
|
||||
|
||||
it('removes a chip from the DOM but keeps the rest when one × is clicked', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedPersons: [makePerson('p1', 'Walter Raddatz')],
|
||||
dateFrom: '1914-01-01',
|
||||
dateTo: '1918-12-31',
|
||||
keywords: ['krieg']
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
expect(container.querySelectorAll('[data-chip-type]')).toHaveLength(3);
|
||||
await page.getByRole('button', { name: /Absender/ }).click();
|
||||
await vi.waitFor(() => expect(container.querySelectorAll('[data-chip-type]')).toHaveLength(2));
|
||||
});
|
||||
|
||||
it('renders a single directional chip with an arrow for a 2-name query', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedPersons: [makePerson('p1', 'Walter Raddatz'), makePerson('p2', 'Emma Raddatz')]
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
expect(container.querySelectorAll('[data-chip-type="directional"]')).toHaveLength(1);
|
||||
await expect.element(page.getByText(/→/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRemoveChip with "directional" when the directional chip × is clicked', async () => {
|
||||
const onRemoveChip = vi.fn();
|
||||
render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedPersons: [makePerson('p1', 'Walter Raddatz'), makePerson('p2', 'Emma Raddatz')]
|
||||
}),
|
||||
onRemoveChip
|
||||
});
|
||||
await page.getByRole('button', { name: /Walter Raddatz/ }).click();
|
||||
expect(onRemoveChip).toHaveBeenCalledWith('directional', undefined);
|
||||
});
|
||||
|
||||
it('does not render keyword chips when keywordsApplied is false', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
keywordsApplied: false,
|
||||
keywords: ['krieg', 'brief']
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders no keyword chips when keywords is empty', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({ keywordsApplied: true, keywords: [] }),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders exactly one keyword chip per keyword', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
keywordsApplied: true,
|
||||
keywords: ['krieg', 'brief', 'front']
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('keeps the × button in the DOM when a display name is 100 characters', async () => {
|
||||
const longName = 'W'.repeat(100);
|
||||
render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedPersons: [makePerson('p1', longName)]
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: new RegExp('Absender') }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── theme chips ─────────────────────────────────────────────────────────────
|
||||
|
||||
const makeTag = (id: string, name: string, color?: string): TagHint => ({ id, name, color });
|
||||
|
||||
it('renders theme chips when tagsApplied is true', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||||
tagsApplied: true
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(1);
|
||||
await expect.element(page.getByText(/Thema: Hochzeit/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no theme chips when tagsApplied is false', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||||
tagsApplied: false
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders exactly N theme chips for N resolved tags', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Krieg'), makeTag('t2', 'Hochzeit'), makeTag('t3', 'Familie')],
|
||||
tagsApplied: true
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('calls onRemoveChip with "theme" and tag name when × is clicked', async () => {
|
||||
const onRemoveChip = vi.fn();
|
||||
render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||||
tagsApplied: true
|
||||
}),
|
||||
onRemoveChip
|
||||
});
|
||||
await page.getByRole('button', { name: /Thema: Hochzeit/ }).click();
|
||||
expect(onRemoveChip).toHaveBeenCalledWith('theme', 'Hochzeit');
|
||||
});
|
||||
|
||||
it('applies inline color style for a tag with a color', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Hochzeit', 'sage')],
|
||||
tagsApplied: true
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
const chip = container.querySelector('[data-chip-type="theme"]') as HTMLElement;
|
||||
expect(chip.style.backgroundColor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('omits color style for a tag with no color', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||||
tagsApplied: true
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
const chip = container.querySelector('[data-chip-type="theme"]') as HTMLElement;
|
||||
expect(chip.getAttribute('style')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { smartMode = $bindable(false), onToggle }: { smartMode?: boolean; onToggle?: () => void } =
|
||||
$props();
|
||||
|
||||
const label = $derived(smartMode ? m.search_toggle_smart_label() : m.search_toggle_keyword_label());
|
||||
const labelSuffix = $derived(
|
||||
smartMode ? m.search_toggle_smart_label_suffix() : m.search_toggle_keyword_label_suffix()
|
||||
);
|
||||
|
||||
function toggle() {
|
||||
smartMode = !smartMode;
|
||||
onToggle?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={smartMode}
|
||||
onclick={toggle}
|
||||
class="pointer-events-auto absolute top-1/2 right-2 flex -translate-y-1/2 cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase outline-none focus-visible:ring-2 focus-visible:ring-brand-navy {smartMode
|
||||
? 'border border-primary bg-primary text-primary-fg'
|
||||
: 'border border-line bg-muted text-ink-2'}"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="h-3.5 w-3.5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M12 2l2.09 6.26L20 10l-5.91 1.74L12 18l-2.09-6.26L4 10l5.91-1.74L12 2z" />
|
||||
</svg>
|
||||
<span>
|
||||
{label}<span class="sm:hidden">{labelSuffix}</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import SmartModeToggle from './SmartModeToggle.svelte';
|
||||
import SearchFilterBar from '../SearchFilterBar.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const SEARCH_PLACEHOLDER = 'Titel, Personen, Tags durchsuchen…';
|
||||
|
||||
describe('SmartModeToggle', () => {
|
||||
it('renders aria-pressed="false" by default and toggles on click', async () => {
|
||||
render(SmartModeToggle, { smartMode: false });
|
||||
const btn = page.getByRole('button');
|
||||
await expect.element(btn).toHaveAttribute('aria-pressed', 'false');
|
||||
await btn.click();
|
||||
await expect.element(btn).toHaveAttribute('aria-pressed', 'true');
|
||||
await btn.click();
|
||||
await expect.element(btn).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('shows the smart label when smartMode is true', async () => {
|
||||
render(SmartModeToggle, { smartMode: true });
|
||||
const btn = page.getByRole('button');
|
||||
await expect.element(btn).toHaveTextContent('KI');
|
||||
});
|
||||
|
||||
it('shows the keyword label when smartMode is false', async () => {
|
||||
render(SmartModeToggle, { smartMode: false });
|
||||
const btn = page.getByRole('button');
|
||||
await expect.element(btn).toHaveTextContent('Text');
|
||||
});
|
||||
|
||||
it('applies the active pill style only in smart mode', async () => {
|
||||
render(SmartModeToggle, { smartMode: true });
|
||||
const btn = page.getByRole('button');
|
||||
await expect.element(btn).toHaveClass(/bg-primary/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SmartModeToggle inside SearchFilterBar', () => {
|
||||
it('adds maxlength="500" to the search input only in smart mode', async () => {
|
||||
render(SearchFilterBar, { onSearch: vi.fn(), sort: 'DATE', dir: 'desc', smartMode: true });
|
||||
await expect
|
||||
.element(page.getByPlaceholder(SEARCH_PLACEHOLDER))
|
||||
.toHaveAttribute('maxlength', '500');
|
||||
});
|
||||
|
||||
it('omits maxlength from the search input in keyword mode', async () => {
|
||||
render(SearchFilterBar, { onSearch: vi.fn(), sort: 'DATE', dir: 'desc', smartMode: false });
|
||||
await expect
|
||||
.element(page.getByPlaceholder(SEARCH_PLACEHOLDER))
|
||||
.not.toHaveAttribute('maxlength');
|
||||
});
|
||||
|
||||
it('does not fire the keyword search on input while in smart mode', async () => {
|
||||
const onSearch = vi.fn();
|
||||
render(SearchFilterBar, { onSearch, sort: 'DATE', dir: 'desc', smartMode: true });
|
||||
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill('Walter im Krieg');
|
||||
expect(onSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires the smart search callback on Enter in smart mode', async () => {
|
||||
const onSmartSearch = vi.fn();
|
||||
render(SearchFilterBar, {
|
||||
onSearch: vi.fn(),
|
||||
onSmartSearch,
|
||||
sort: 'DATE',
|
||||
dir: 'desc',
|
||||
smartMode: true
|
||||
});
|
||||
const input = page.getByPlaceholder(SEARCH_PLACEHOLDER);
|
||||
await input.fill('Walter im Krieg');
|
||||
await input.click();
|
||||
// Enter submits the NL query in smart mode
|
||||
(document.activeElement as HTMLElement).dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
|
||||
);
|
||||
await vi.waitFor(() => expect(onSmartSearch).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type SmartSearchErrorCode = 'SMART_SEARCH_UNAVAILABLE' | 'SMART_SEARCH_RATE_LIMITED';
|
||||
|
||||
let {
|
||||
status,
|
||||
errorCode,
|
||||
onSwitchToKeyword
|
||||
}: {
|
||||
status: 'loading' | 'error';
|
||||
errorCode?: SmartSearchErrorCode;
|
||||
onSwitchToKeyword?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isRateLimited = $derived(errorCode === 'SMART_SEARCH_RATE_LIMITED');
|
||||
const title = $derived(
|
||||
isRateLimited ? m.search_error_rate_limited() : m.search_error_unavailable()
|
||||
);
|
||||
const body = $derived(
|
||||
isRateLimited ? m.search_error_rate_limited_body() : m.search_error_unavailable_body()
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if status === 'loading'}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="flex flex-col items-center justify-center gap-3 py-16 text-center"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="h-9 w-9 rounded-full border-[3px] border-primary/12 border-t-primary motion-safe:animate-spin"
|
||||
></div>
|
||||
<p class="text-sm font-bold text-ink">{m.search_loading_nl()}</p>
|
||||
<p class="max-w-xs text-xs text-ink-3 motion-safe:animate-pulse">
|
||||
{m.search_loading_nl_sub()}
|
||||
</p>
|
||||
</div>
|
||||
{:else if status === 'error'}
|
||||
<div role="alert" class="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border-2 text-lg font-bold {isRateLimited
|
||||
? 'border-amber-400 bg-amber-50 text-amber-600'
|
||||
: 'border-red-400 bg-red-50 text-red-600'}"
|
||||
>
|
||||
{#if isRateLimited}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{:else}
|
||||
<span>!</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm font-bold text-ink">{title}</p>
|
||||
<p class="max-w-xs text-xs text-ink-3">{body}</p>
|
||||
{#if !isRateLimited}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onSwitchToKeyword}
|
||||
class="mt-2 inline-flex min-h-[44px] items-center rounded border border-primary bg-primary px-4 py-2 text-sm font-bold text-primary-fg outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
{m.search_switch_to_keyword()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import SmartSearchStatus from './SmartSearchStatus.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('SmartSearchStatus', () => {
|
||||
it('renders a role="status" loading panel with the loading title', async () => {
|
||||
render(SmartSearchStatus, { status: 'loading' });
|
||||
const status = page.getByRole('status');
|
||||
await expect.element(status).toBeInTheDocument();
|
||||
await expect.element(status).toHaveTextContent('Archiv wird befragt');
|
||||
});
|
||||
|
||||
it('hides the loading panel once the status changes away from loading', async () => {
|
||||
const { rerender } = render(SmartSearchStatus, { status: 'loading' });
|
||||
await expect.element(page.getByRole('status')).toBeInTheDocument();
|
||||
await rerender({ status: 'error', errorCode: 'SMART_SEARCH_UNAVAILABLE' });
|
||||
await expect.element(page.getByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the 503 panel with title, body and a switch-to-keyword button', async () => {
|
||||
render(SmartSearchStatus, {
|
||||
status: 'error',
|
||||
errorCode: 'SMART_SEARCH_UNAVAILABLE',
|
||||
onSwitchToKeyword: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText('Intelligente Suche nicht verfügbar')).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Volltextsuche wechseln/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invokes onSwitchToKeyword when the 503 fallback button is clicked', async () => {
|
||||
const onSwitchToKeyword = vi.fn();
|
||||
render(SmartSearchStatus, {
|
||||
status: 'error',
|
||||
errorCode: 'SMART_SEARCH_UNAVAILABLE',
|
||||
onSwitchToKeyword
|
||||
});
|
||||
await page.getByRole('button', { name: /Volltextsuche wechseln/ }).click();
|
||||
expect(onSwitchToKeyword).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders the 429 panel with title and body but no switch-to-keyword button', async () => {
|
||||
render(SmartSearchStatus, {
|
||||
status: 'error',
|
||||
errorCode: 'SMART_SEARCH_RATE_LIMITED',
|
||||
onSwitchToKeyword: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText('Zu viele Anfragen')).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Volltextsuche wechseln/ }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export type ChipType = 'sender' | 'directional' | 'date' | 'keyword' | 'theme';
|
||||
@@ -1,218 +0,0 @@
|
||||
{
|
||||
"id": null,
|
||||
"uid": "ollama-dashboard",
|
||||
"title": "Ollama",
|
||||
"description": "Ollama inference latency and request rate",
|
||||
"version": 1,
|
||||
"schemaVersion": 39,
|
||||
"tags": ["ollama", "inference"],
|
||||
"timezone": "browser",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"refresh": "30s",
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"weekStart": "",
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": { "type": "datasource", "uid": "grafana" },
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "timeseries",
|
||||
"title": "Inference Latency p50",
|
||||
"description": "50th percentile of Ollama request duration over a 5-minute window",
|
||||
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "red", "value": 80 }
|
||||
]
|
||||
},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "single", "sort": "none" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"editorMode": "code",
|
||||
"expr": "histogram_quantile(0.5, rate(ollama_request_duration_seconds_bucket[5m]))",
|
||||
"instant": false,
|
||||
"legendFormat": "p50",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "timeseries",
|
||||
"title": "Inference Latency p95",
|
||||
"description": "95th percentile of Ollama request duration over a 5-minute window",
|
||||
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "red", "value": 80 }
|
||||
]
|
||||
},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "single", "sort": "none" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"editorMode": "code",
|
||||
"expr": "histogram_quantile(0.95, rate(ollama_request_duration_seconds_bucket[5m]))",
|
||||
"instant": false,
|
||||
"legendFormat": "p95",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "timeseries",
|
||||
"title": "Request Rate",
|
||||
"description": "Ollama requests per second over a 5-minute window",
|
||||
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "red", "value": 80 }
|
||||
]
|
||||
},
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "single", "sort": "none" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"editorMode": "code",
|
||||
"expr": "rate(ollama_requests_total[5m])",
|
||||
"instant": false,
|
||||
"legendFormat": "req/s",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"preload": false,
|
||||
"templating": {
|
||||
"list": []
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,3 @@ scrape_configs:
|
||||
static_configs:
|
||||
- targets: ['ocr-service:8000']
|
||||
|
||||
- job_name: ollama
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
# Uses the Docker service name for reliable DNS resolution.
|
||||
- targets: ['ollama:11434']
|
||||
|
||||
Reference in New Issue
Block a user