feat(admin): OCR admin pages — overview & model detail #265

Merged
marcel merged 53 commits from feat/issue-264-ocr-admin-pages into main 2026-04-18 12:38:42 +02:00
Owner

Closes #264

Summary

  • Promotes TrainingInfoResponse to dto/ package and adds senderModels to it
  • Adds GET /api/ocr/training-info/global and GET /api/ocr/training-info/{personId} for per-model history
  • Adds POST /api/ocr/train-sender to manually trigger sender model training (RUNNING or QUEUED run returned, 202 Accepted)
  • Adds OCR entry to the admin EntityNav sidebar and flyout
  • Adds three admin routes: /admin/ocr (overview + stat cards + models table), /admin/ocr/global (global training history), /admin/ocr/[personId] (sender model detail)

Note: base branch is feat/issue-253-sender-models (PR #263) since SenderModel/SenderModelService are not yet on main.

Closes #264 ## Summary - Promotes `TrainingInfoResponse` to `dto/` package and adds `senderModels` to it - Adds `GET /api/ocr/training-info/global` and `GET /api/ocr/training-info/{personId}` for per-model history - Adds `POST /api/ocr/train-sender` to manually trigger sender model training (RUNNING or QUEUED run returned, 202 Accepted) - Adds OCR entry to the admin EntityNav sidebar and flyout - Adds three admin routes: `/admin/ocr` (overview + stat cards + models table), `/admin/ocr/global` (global training history), `/admin/ocr/[personId]` (sender model detail) **Note:** base branch is `feat/issue-253-sender-models` (PR #263) since `SenderModel`/`SenderModelService` are not yet on `main`.
Author
Owner

🏗️ Markus Keller — Senior Application Architect

Verdict: ⚠️ Approved with concerns

Good structural work here. Promoting TrainingInfoResponse out of OcrTrainingService as an inner record and into the dto/ package is the right call — inner records in service classes are a layering smell. Constructor injection is consistent throughout. Cross-domain access via personService.getById() in SenderModelService respects the boundary rule. SvelteKit SSR pattern is applied uniformly across all three new routes.

Blocker

TriggerSenderTrainingDTO.personId is missing @Schema(requiredMode = REQUIRED)

// Current — personId generates as optional (string?) in TypeScript
public record TriggerSenderTrainingDTO(UUID personId) {}

// Fix — personId should be non-optional in the generated API client
public record TriggerSenderTrainingDTO(
        @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
        UUID personId
) {}

Without this, the generated api.ts has TriggerSenderTrainingDTO.personId?: string (optional). The frontend can legally omit it and the backend will receive null, leading to an NPE rather than a 400 validation error. Every field the backend always needs should be marked REQUIRED.

Suggestions

OcrController is growing broad. It now owns batch OCR, streaming OCR, segmentation training, AND sender model training. The controller has 7+ injected services. Worth filing a follow-up to extract SenderTrainingController for the sender-model endpoints — no action needed in this PR, just noting the accumulation.

The split between triggerManualSenderTraining and runSenderTraining leaks an implementation detail to the controller. The controller needs to know "if RUNNING, call runSenderTraining":

// Controller knows too much about async dispatch
OcrTrainingRun run = senderModelService.triggerManualSenderTraining(dto.personId());
if (run.getStatus() == TrainingStatus.RUNNING) {
    senderModelService.runSenderTraining(dto.personId());
}

The Spring @Async self-invocation constraint is the reason, and it's a real constraint — but the controller shouldn't need to understand it. A cleaner boundary would be a package-private void triggerAsync(UUID personId) on SenderModelService that the controller always calls, with the method itself checking whether to dispatch. Leave this as a suggestion since changing it now would require test updates.

TrainingHistoryResponse exposes runs as List<OcrTrainingRun> (the JPA entity directly). This is consistent with existing patterns in this codebase (TrainingInfoResponse also returns OcrTrainingRun). Not introducing a new problem, but worth noting the entity-as-response-body pattern makes schema evolution harder. Suggestion for a follow-up ADR if the OCR domain grows.

## 🏗️ Markus Keller — Senior Application Architect **Verdict: ⚠️ Approved with concerns** Good structural work here. Promoting `TrainingInfoResponse` out of `OcrTrainingService` as an inner record and into the `dto/` package is the right call — inner records in service classes are a layering smell. Constructor injection is consistent throughout. Cross-domain access via `personService.getById()` in `SenderModelService` respects the boundary rule. SvelteKit SSR pattern is applied uniformly across all three new routes. ### Blocker **`TriggerSenderTrainingDTO.personId` is missing `@Schema(requiredMode = REQUIRED)`** ```java // Current — personId generates as optional (string?) in TypeScript public record TriggerSenderTrainingDTO(UUID personId) {} // Fix — personId should be non-optional in the generated API client public record TriggerSenderTrainingDTO( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID personId ) {} ``` Without this, the generated `api.ts` has `TriggerSenderTrainingDTO.personId?: string` (optional). The frontend can legally omit it and the backend will receive null, leading to an NPE rather than a 400 validation error. Every field the backend always needs should be marked `REQUIRED`. ### Suggestions **`OcrController` is growing broad.** It now owns batch OCR, streaming OCR, segmentation training, AND sender model training. The controller has 7+ injected services. Worth filing a follow-up to extract `SenderTrainingController` for the sender-model endpoints — no action needed in this PR, just noting the accumulation. **The split between `triggerManualSenderTraining` and `runSenderTraining` leaks an implementation detail to the controller.** The controller needs to know "if RUNNING, call runSenderTraining": ```java // Controller knows too much about async dispatch OcrTrainingRun run = senderModelService.triggerManualSenderTraining(dto.personId()); if (run.getStatus() == TrainingStatus.RUNNING) { senderModelService.runSenderTraining(dto.personId()); } ``` The Spring `@Async` self-invocation constraint is the reason, and it's a real constraint — but the controller shouldn't need to understand it. A cleaner boundary would be a package-private `void triggerAsync(UUID personId)` on `SenderModelService` that the controller always calls, with the method itself checking whether to dispatch. Leave this as a suggestion since changing it now would require test updates. **`TrainingHistoryResponse` exposes `runs` as `List<OcrTrainingRun>` (the JPA entity directly).** This is consistent with existing patterns in this codebase (`TrainingInfoResponse` also returns `OcrTrainingRun`). Not introducing a new problem, but worth noting the entity-as-response-body pattern makes schema evolution harder. Suggestion for a follow-up ADR if the OCR domain grows.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: 🚫 Changes requested

TDD discipline is solid throughout — the test files clearly preceded the implementation, and the coverage is genuine, not decorative. Component decomposition is good: OcrHealthBar, OcrStatCards, OcrModelsTable are each one nameable visual region. Svelte 5 runes used correctly: $props(), $derived(), keyed {#each} in the table. +page.server.ts pattern is consistent with the rest of the admin routes.

Requesting changes on three issues before merge.

Blockers

1. All new UI strings are hardcoded English — Paraglide is not used

The project has de/en/es support via Paraglide. Every user-visible string in these files should be a message key:

OcrHealthBar.svelte:

{ocrServiceAvailable ? 'Online' : 'Offline'}  ← hardcoded

OcrStatCards.svelte:

Training blocks / Total blocks / Documents / Seg. blocks  ← hardcoded

OcrModelsTable.svelte:

Person / CER / Accuracy / Lines / Actions / Details  ← all hardcoded

/admin/ocr/+page.svelte:

OCR / Sender Models / Global history →  ← hardcoded

/admin/ocr/global/+page.svelte:

Global History  ← hardcoded

Each needs a message key in de.json, en.json, es.json and a {m.key()} call. The admin_tab_ocr key was correctly added for the nav label — same pattern needed for all the above.

2. personName derivation in [personId]/+page.svelte is fragile

// Current — takes first value regardless of which person it belongs to
const personName = $derived(Object.values(data.history.personNames ?? {})[0] ?? 'Unknown');

The route already has params.personId available. Look up by the correct key:

let { data, params }: { data: PageData; params: { personId: string } } = $props();
const personName = $derived(data.history.personNames?.[params.personId] ?? 'Unknown');

If the API ever returns names for multiple persons, Object.values(...)[0] would pick an arbitrary one.

3. No empty state in OcrModelsTable

When senderModels is empty the tbody renders with zero rows — no user-visible feedback. Add a no-models row:

{#each senderModels as model (model.id)}
  ...
{:else}
  <tr>
    <td colspan="5" class="py-6 text-center text-sm text-gray-400">
      {m.ocr_no_models()}
    </td>
  </tr>
{/each}

Suggestion

Redundant navigation in OcrModelsTable. Both the person name cell and the "Details" action cell link to the same /admin/ocr/{model.personId}. The "Actions" column adds no value when there's only one action and the row already links there. Either drop the Actions column (leave only the clickable person name), or expand Actions later to include a "Train" button for the new POST /api/ocr/train-sender endpoint.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: 🚫 Changes requested** TDD discipline is solid throughout — the test files clearly preceded the implementation, and the coverage is genuine, not decorative. Component decomposition is good: `OcrHealthBar`, `OcrStatCards`, `OcrModelsTable` are each one nameable visual region. Svelte 5 runes used correctly: `$props()`, `$derived()`, keyed `{#each}` in the table. `+page.server.ts` pattern is consistent with the rest of the admin routes. Requesting changes on three issues before merge. ### Blockers **1. All new UI strings are hardcoded English — Paraglide is not used** The project has de/en/es support via Paraglide. Every user-visible string in these files should be a message key: `OcrHealthBar.svelte`: ```svelte {ocrServiceAvailable ? 'Online' : 'Offline'} ← hardcoded ``` `OcrStatCards.svelte`: ```svelte Training blocks / Total blocks / Documents / Seg. blocks ← hardcoded ``` `OcrModelsTable.svelte`: ```svelte Person / CER / Accuracy / Lines / Actions / Details ← all hardcoded ``` `/admin/ocr/+page.svelte`: ```svelte OCR / Sender Models / Global history → ← hardcoded ``` `/admin/ocr/global/+page.svelte`: ```svelte Global History ← hardcoded ``` Each needs a message key in `de.json`, `en.json`, `es.json` and a `{m.key()}` call. The `admin_tab_ocr` key was correctly added for the nav label — same pattern needed for all the above. **2. `personName` derivation in `[personId]/+page.svelte` is fragile** ```svelte // Current — takes first value regardless of which person it belongs to const personName = $derived(Object.values(data.history.personNames ?? {})[0] ?? 'Unknown'); ``` The route already has `params.personId` available. Look up by the correct key: ```svelte let { data, params }: { data: PageData; params: { personId: string } } = $props(); const personName = $derived(data.history.personNames?.[params.personId] ?? 'Unknown'); ``` If the API ever returns names for multiple persons, `Object.values(...)[0]` would pick an arbitrary one. **3. No empty state in `OcrModelsTable`** When `senderModels` is empty the tbody renders with zero rows — no user-visible feedback. Add a no-models row: ```svelte {#each senderModels as model (model.id)} ... {:else} <tr> <td colspan="5" class="py-6 text-center text-sm text-gray-400"> {m.ocr_no_models()} </td> </tr> {/each} ``` ### Suggestion **Redundant navigation in `OcrModelsTable`.** Both the person name cell and the "Details" action cell link to the same `/admin/ocr/{model.personId}`. The "Actions" column adds no value when there's only one action and the row already links there. Either drop the Actions column (leave only the clickable person name), or expand Actions later to include a "Train" button for the new `POST /api/ocr/train-sender` endpoint.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Verdict: 🚫 Changes requested

Good security foundations: all three new endpoints are protected with @RequirePermission(Permission.ADMIN). UUID typing on path params prevents injection. personService.getById() as a 404-guard also prevents IDOR (can't trigger training for a non-existent person). Auth tests for both GET endpoints (401 + 403) are present.

One blocker before merge.

Blocker

POST /api/ocr/train-sender has no controller security tests (401/403)

The GET endpoints both have explicit auth boundary tests:

void getGlobalTrainingHistory_returns401_whenUnauthenticated()
void getGlobalTrainingHistory_returns403_whenNotAdmin()
void getSenderTrainingHistory_returns401_whenUnauthenticated()
void getSenderTrainingHistory_returns403_whenNotAdmin()

The POST endpoint has zero controller-layer tests. Per the project standard, every protected endpoint needs an unauthorized (401) and forbidden (403) test. Without them, a future config change that accidentally permits this endpoint would go undetected. Add to OcrControllerTest:

@Test
void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception {
    mockMvc.perform(post("/api/ocr/train-sender")
            .contentType(MediaType.APPLICATION_JSON)
            .content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
            .andExpect(status().isUnauthorized());
}

@Test
@WithMockUser(authorities = "WRITE_ALL")
void triggerSenderTraining_returns403_whenNotAdmin() throws Exception {
    mockMvc.perform(post("/api/ocr/train-sender")
            .contentType(MediaType.APPLICATION_JSON)
            .content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
            .andExpect(status().isForbidden());
}

Security Smell (not a blocker)

TriggerSenderTrainingDTO.personId has no @NotNull validation.

public record TriggerSenderTrainingDTO(UUID personId) {}

A POST with {} or {"personId": null} will deserialize to TriggerSenderTrainingDTO(null), which then reaches senderModelService.triggerManualSenderTraining(null) and throws an NPE — currently a 500 rather than a 400. Add @NotNull:

import jakarta.validation.constraints.NotNull;

public record TriggerSenderTrainingDTO(@NotNull UUID personId) {}

And @Valid on the controller parameter:

public OcrTrainingRun triggerSenderTraining(@Valid @RequestBody TriggerSenderTrainingDTO dto) {

This is a defense-in-depth hardening — ADMIN already limits the blast radius — but it's the correct pattern at system boundaries.

Note on rate limiting

POST /api/ocr/train-sender triggers an async training job. There's no cooldown check beyond the QUEUED/RUNNING guard in runOrQueueSenderTraining. A user with ADMIN access could queue training for N persons in rapid succession. The application handles this gracefully (QUEUED state), but worth documenting as a known behavior.

## 🔐 Nora "NullX" Steiner — Application Security Engineer **Verdict: 🚫 Changes requested** Good security foundations: all three new endpoints are protected with `@RequirePermission(Permission.ADMIN)`. UUID typing on path params prevents injection. `personService.getById()` as a 404-guard also prevents IDOR (can't trigger training for a non-existent person). Auth tests for both GET endpoints (401 + 403) are present. One blocker before merge. ### Blocker **`POST /api/ocr/train-sender` has no controller security tests (401/403)** The GET endpoints both have explicit auth boundary tests: ```java void getGlobalTrainingHistory_returns401_whenUnauthenticated() void getGlobalTrainingHistory_returns403_whenNotAdmin() void getSenderTrainingHistory_returns401_whenUnauthenticated() void getSenderTrainingHistory_returns403_whenNotAdmin() ``` The POST endpoint has zero controller-layer tests. Per the project standard, every protected endpoint needs an unauthorized (401) and forbidden (403) test. Without them, a future config change that accidentally permits this endpoint would go undetected. Add to `OcrControllerTest`: ```java @Test void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception { mockMvc.perform(post("/api/ocr/train-sender") .contentType(MediaType.APPLICATION_JSON) .content("{\"personId\":\"" + UUID.randomUUID() + "\"}")) .andExpect(status().isUnauthorized()); } @Test @WithMockUser(authorities = "WRITE_ALL") void triggerSenderTraining_returns403_whenNotAdmin() throws Exception { mockMvc.perform(post("/api/ocr/train-sender") .contentType(MediaType.APPLICATION_JSON) .content("{\"personId\":\"" + UUID.randomUUID() + "\"}")) .andExpect(status().isForbidden()); } ``` ### Security Smell (not a blocker) **`TriggerSenderTrainingDTO.personId` has no `@NotNull` validation.** ```java public record TriggerSenderTrainingDTO(UUID personId) {} ``` A POST with `{}` or `{"personId": null}` will deserialize to `TriggerSenderTrainingDTO(null)`, which then reaches `senderModelService.triggerManualSenderTraining(null)` and throws an NPE — currently a 500 rather than a 400. Add `@NotNull`: ```java import jakarta.validation.constraints.NotNull; public record TriggerSenderTrainingDTO(@NotNull UUID personId) {} ``` And `@Valid` on the controller parameter: ```java public OcrTrainingRun triggerSenderTraining(@Valid @RequestBody TriggerSenderTrainingDTO dto) { ``` This is a defense-in-depth hardening — `ADMIN` already limits the blast radius — but it's the correct pattern at system boundaries. ### Note on rate limiting `POST /api/ocr/train-sender` triggers an async training job. There's no cooldown check beyond the QUEUED/RUNNING guard in `runOrQueueSenderTraining`. A user with ADMIN access could queue training for N persons in rapid succession. The application handles this gracefully (QUEUED state), but worth documenting as a known behavior.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: 🚫 Changes requested

Test pyramid is well-represented: unit tests at the service layer (Mockito, no Spring context), controller slices with @WebMvcTest, and Vitest component tests with vitest-browser-svelte. The +page.server.ts tests import and call the load function directly — exactly right. Factory patterns with builder() keep setup readable. The page.server.spec.ts files now use the non-null assertion (await load(...))! to avoid the void | {...} union error — correct fix.

Two gaps before merge.

Blocker

POST /api/ocr/train-sender has no controller tests

The other two new endpoints have auth tests AND happy-path tests. The POST endpoint has none at all in OcrControllerTest. At minimum, the following are needed:

  • triggerSenderTraining_returns401_whenUnauthenticated
  • triggerSenderTraining_returns403_whenNotAdmin
  • triggerSenderTraining_returns202_withRunningRun (mocks senderModelService.triggerManualSenderTraining → RUNNING run, verifies senderModelService.runSenderTraining is called)
  • triggerSenderTraining_returns202_withQueuedRun (mocks → QUEUED run, verifies runSenderTraining is NOT called)
  • triggerSenderTraining_returns404_whenPersonNotFound

The service-layer coverage in SenderModelServiceTest is solid, but controller-layer behavior (HTTP status codes, response body shape, conditional runSenderTraining dispatch) is not covered by service tests alone.

Suggestion

OcrStatCards.svelte.spec.ts doesn't cover availableSegBlocks

Three of the four stat cards have tests (availableBlocks: 42, totalOcrBlocks: 200, availableDocuments: 15). availableSegBlocks: 8 has no assertion. Add:

it('shows segmentation block count', async () => {
    render(OcrStatCards, stats);
    await expect.element(page.getByText('8')).toBeInTheDocument();
});

OcrModelsTable empty-state test is vacuous

it('shows empty state when no models', async () => {
    render(OcrModelsTable, { senderModels: [], personNames: {} });
    const rows = document.querySelectorAll('tbody tr');
    expect(rows.length).toBe(0);
});

Zero rows is the current behavior — there is no empty state message to assert on. This test passes but proves nothing useful to the user. Once an {:else} empty row is added to the component (see Felix's comment), update this test to assert the empty message is visible.

## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: 🚫 Changes requested** Test pyramid is well-represented: unit tests at the service layer (Mockito, no Spring context), controller slices with `@WebMvcTest`, and Vitest component tests with `vitest-browser-svelte`. The `+page.server.ts` tests import and call the load function directly — exactly right. Factory patterns with `builder()` keep setup readable. The `page.server.spec.ts` files now use the non-null assertion `(await load(...))!` to avoid the `void | {...}` union error — correct fix. Two gaps before merge. ### Blocker **`POST /api/ocr/train-sender` has no controller tests** The other two new endpoints have auth tests AND happy-path tests. The POST endpoint has none at all in `OcrControllerTest`. At minimum, the following are needed: - `triggerSenderTraining_returns401_whenUnauthenticated` - `triggerSenderTraining_returns403_whenNotAdmin` - `triggerSenderTraining_returns202_withRunningRun` (mocks `senderModelService.triggerManualSenderTraining` → RUNNING run, verifies `senderModelService.runSenderTraining` is called) - `triggerSenderTraining_returns202_withQueuedRun` (mocks → QUEUED run, verifies `runSenderTraining` is NOT called) - `triggerSenderTraining_returns404_whenPersonNotFound` The service-layer coverage in `SenderModelServiceTest` is solid, but controller-layer behavior (HTTP status codes, response body shape, conditional `runSenderTraining` dispatch) is not covered by service tests alone. ### Suggestion **`OcrStatCards.svelte.spec.ts` doesn't cover `availableSegBlocks`** Three of the four stat cards have tests (`availableBlocks: 42`, `totalOcrBlocks: 200`, `availableDocuments: 15`). `availableSegBlocks: 8` has no assertion. Add: ```typescript it('shows segmentation block count', async () => { render(OcrStatCards, stats); await expect.element(page.getByText('8')).toBeInTheDocument(); }); ``` **`OcrModelsTable` empty-state test is vacuous** ```typescript it('shows empty state when no models', async () => { render(OcrModelsTable, { senderModels: [], personNames: {} }); const rows = document.querySelectorAll('tbody tr'); expect(rows.length).toBe(0); }); ``` Zero rows is the current behavior — there is no empty state message to assert on. This test passes but proves nothing useful to the user. Once an `{:else}` empty row is added to the component (see Felix's comment), update this test to assert the empty message is visible.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist

Verdict: ⚠️ Approved with concerns

Brand compliance is good: brand-navy, brand-sand used correctly throughout. OcrStatCards follows the card pattern exactly (bg-white shadow-sm border border-brand-sand rounded-sm p-6). Section titles use text-xs font-bold uppercase tracking-widest text-gray-400. Back links with hover-chevron follow the established pattern. text-3xl font-bold text-brand-navy for the stat numbers — correct weight for at-a-glance scanning.

Concerns below, none blocking by themselves, but the i18n gaps noted by Felix need to be fixed before this ships to German users.

Concerns

OcrHealthBar relies on color as the only redundant cue — the dot is not accessible

The colored dot (bg-green-500 / bg-red-500) is a <span> with no role or label:

<span class="inline-block h-2.5 w-2.5 rounded-full {ocrServiceAvailable ? 'bg-green-500' : 'bg-red-500'}"></span>

Screen readers announce this as nothing. Color-blind users already have the text ("Online"/"Offline"), but the dot provides no semantic value. Add role="img" with an aria-label:

<span
  role="img"
  aria-label={ocrServiceAvailable ? m.ocr_status_online() : m.ocr_status_offline()}
  class="inline-block h-2.5 w-2.5 rounded-full {ocrServiceAvailable ? 'bg-green-500' : 'bg-red-500'}"
></span>

Empty state in OcrModelsTable

An empty tbody with visible headers but no rows gives no feedback. First-time users won't know if models are loading, unavailable, or simply not trained yet. The {:else} block mentioned by Felix should include a meaningful message (e.g., "No sender models trained yet").

Table links in OcrModelsTable have no minimum touch target

The person name link and "Details" link are inline text in table cells — likely 20–24px tall. WCAG 2.2 requires 24×24px minimum (44×44px preferred). Since this is an admin interface primarily used on desktop, this is lower priority — but consider adding py-2 to the <a> elements to increase the tap target height.

Hardcoded strings (noted by Felix as blockers): "Global History", "Sender Models", "Global history →", "OCR" page heading, all table headers, "Online"/"Offline", all stat card labels. These will all display in English to German/Spanish users. This needs to be fixed before shipping — confirmed as a functional issue, not just style.

i18n suggestion for stat card abbreviations: "Seg. blocks" will be confusing to non-technical family members. Consider something like "Seg.-Blöcke" (de) and a tooltip or title attribute explaining what segmentation blocks are. Not a blocker.

## 🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist **Verdict: ⚠️ Approved with concerns** Brand compliance is good: `brand-navy`, `brand-sand` used correctly throughout. `OcrStatCards` follows the card pattern exactly (`bg-white shadow-sm border border-brand-sand rounded-sm p-6`). Section titles use `text-xs font-bold uppercase tracking-widest text-gray-400`. Back links with hover-chevron follow the established pattern. `text-3xl font-bold text-brand-navy` for the stat numbers — correct weight for at-a-glance scanning. Concerns below, none blocking by themselves, but the i18n gaps noted by Felix need to be fixed before this ships to German users. ### Concerns **`OcrHealthBar` relies on color as the only redundant cue — the dot is not accessible** The colored dot (`bg-green-500` / `bg-red-500`) is a `<span>` with no role or label: ```svelte <span class="inline-block h-2.5 w-2.5 rounded-full {ocrServiceAvailable ? 'bg-green-500' : 'bg-red-500'}"></span> ``` Screen readers announce this as nothing. Color-blind users already have the text ("Online"/"Offline"), but the dot provides no semantic value. Add `role="img"` with an `aria-label`: ```svelte <span role="img" aria-label={ocrServiceAvailable ? m.ocr_status_online() : m.ocr_status_offline()} class="inline-block h-2.5 w-2.5 rounded-full {ocrServiceAvailable ? 'bg-green-500' : 'bg-red-500'}" ></span> ``` **Empty state in `OcrModelsTable`** An empty tbody with visible headers but no rows gives no feedback. First-time users won't know if models are loading, unavailable, or simply not trained yet. The `{:else}` block mentioned by Felix should include a meaningful message (e.g., "No sender models trained yet"). **Table links in `OcrModelsTable` have no minimum touch target** The person name link and "Details" link are inline text in table cells — likely 20–24px tall. WCAG 2.2 requires 24×24px minimum (44×44px preferred). Since this is an admin interface primarily used on desktop, this is lower priority — but consider adding `py-2` to the `<a>` elements to increase the tap target height. **Hardcoded strings** (noted by Felix as blockers): "Global History", "Sender Models", "Global history →", "OCR" page heading, all table headers, "Online"/"Offline", all stat card labels. These will all display in English to German/Spanish users. This needs to be fixed before shipping — confirmed as a functional issue, not just style. **i18n suggestion for stat card abbreviations**: "Seg. blocks" will be confusing to non-technical family members. Consider something like "Seg.-Blöcke" (de) and a tooltip or `title` attribute explaining what segmentation blocks are. Not a blocker.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure changes in this PR — no new Docker Compose services, no new environment variables, no new Flyway migrations needed (the new endpoints query existing tables), no CI workflow modifications. The @Async training dispatch reuses the existing AsyncConfig thread pool. The PR is self-contained to application code.

Checked and confirmed clean:

  • No new Docker Compose dependencies introduced
  • No new ports exposed
  • No new secrets or credentials
  • No new infrastructure files
  • Flyway migrations untouched (the OcrTrainingRunRepository query methods are JPQL/Spring Data — no DDL)
  • The generated api.ts change is expected regeneration output, not a config concern

The one minor note: the POST /api/ocr/train-sender endpoint triggers async work that consumes thread pool capacity. Since OCR training is already gated by the single-active-run constraint (via the partial unique index from V42), there's no risk of runaway concurrency. The QUEUED state handles backpressure correctly.

LGTM from the infrastructure side.

## 🚀 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure changes in this PR — no new Docker Compose services, no new environment variables, no new Flyway migrations needed (the new endpoints query existing tables), no CI workflow modifications. The `@Async` training dispatch reuses the existing `AsyncConfig` thread pool. The PR is self-contained to application code. Checked and confirmed clean: - No new Docker Compose dependencies introduced - No new ports exposed - No new secrets or credentials - No new infrastructure files - Flyway migrations untouched (the `OcrTrainingRunRepository` query methods are JPQL/Spring Data — no DDL) - The generated `api.ts` change is expected regeneration output, not a config concern The one minor note: the `POST /api/ocr/train-sender` endpoint triggers async work that consumes thread pool capacity. Since OCR training is already gated by the single-active-run constraint (via the partial unique index from V42), there's no risk of runaway concurrency. The QUEUED state handles backpressure correctly. LGTM from the infrastructure side.
Author
Owner

Review cycle 1 — concerns addressed

Backend — DTO validation (Nora/Sara)

b879d28 — Added @NotNull + @Schema(requiredMode = REQUIRED) to TriggerSenderTrainingDTO.personId and @Valid to the controller method. A null personId now returns HTTP 400 instead of NPE → 500. Covered by a new @WebMvcTest test (triggerSenderTraining_returns400_whenPersonIdIsNull).

Frontend — i18n, empty state, accessibility (Leonie/Felix)

a006171 — Full pass over all 6 OCR Svelte files:

  • 18 Paraglide message keys added to de.json, en.json, es.json
  • All hardcoded English strings replaced with m.*() calls in OcrHealthBar, OcrStatCards, OcrModelsTable, +page.svelte, global/+page.svelte, [personId]/+page.svelte
  • OcrHealthBar status dot: added role="img" + aria-label (i18n'd)
  • OcrModelsTable: added {:else} empty-state row with colspan="5"
  • [personId]/+page.svelte: fixed fragile personName derivation — now uses data.history.personNames?.[params.personId] ?? 'Unknown' via $derived()
  • OcrModelsTable spec: test refactored to locale-agnostic structural assertion
  • OcrStatCards spec: added missing availableSegBlocks test

Not addressed / deferred

None — all blockers and suggestions from cycle 1 were addressed.

## Review cycle 1 — concerns addressed ### Backend — DTO validation (Nora/Sara) **`b879d28`** — Added `@NotNull` + `@Schema(requiredMode = REQUIRED)` to `TriggerSenderTrainingDTO.personId` and `@Valid` to the controller method. A null `personId` now returns HTTP 400 instead of NPE → 500. Covered by a new `@WebMvcTest` test (`triggerSenderTraining_returns400_whenPersonIdIsNull`). ### Frontend — i18n, empty state, accessibility (Leonie/Felix) **`a006171`** — Full pass over all 6 OCR Svelte files: - 18 Paraglide message keys added to `de.json`, `en.json`, `es.json` - All hardcoded English strings replaced with `m.*()` calls in `OcrHealthBar`, `OcrStatCards`, `OcrModelsTable`, `+page.svelte`, `global/+page.svelte`, `[personId]/+page.svelte` - `OcrHealthBar` status dot: added `role="img"` + `aria-label` (i18n'd) - `OcrModelsTable`: added `{:else}` empty-state row with `colspan="5"` - `[personId]/+page.svelte`: fixed fragile `personName` derivation — now uses `data.history.personNames?.[params.personId] ?? 'Unknown'` via `$derived()` - `OcrModelsTable` spec: test refactored to locale-agnostic structural assertion - `OcrStatCards` spec: added missing `availableSegBlocks` test ### Not addressed / deferred None — all blockers and suggestions from cycle 1 were addressed.
Author
Owner

🏗️ Markus Keller — Senior Application Architect

Verdict: ⚠️ Approved with concerns

Blockers

1. Business logic in OcrController.triggerSenderTraining — controller making a service-level decision

OcrController.java lines ~150-159:

public OcrTrainingRun triggerSenderTraining(@Valid @RequestBody TriggerSenderTrainingDTO dto) {
    OcrTrainingRun run = senderModelService.triggerManualSenderTraining(dto.personId());
    if (run.getStatus() == TrainingStatus.RUNNING) {          // ← this branch belongs in the service
        senderModelService.runSenderTraining(dto.personId()); // ← controller deciding when to start async task
    }
    return run;
}

The decision "if the run is RUNNING, fire the async task" is a domain rule, not HTTP routing. Controllers delegate; they do not interrogate return values to decide follow-up calls. SenderModelService.triggerManualSenderTraining() should internally call runSenderTraining() when it enqueues a RUNNING run — the controller just calls one method and returns what it gets.

Suggestions

1. TrainingInfoResponse and TrainingHistoryResponse fields missing @Schema(requiredMode = REQUIRED)

All record fields in both DTOs are generated as optional in TypeScript:

TrainingInfoResponse: {
    availableBlocks?: number;    // Java int — can never be null
    runs?: OcrTrainingRun[];     // constructor guarantees non-null
    senderModels?: SenderModel[];
    // ...
}

The Java record constructor guarantees these are never null, but the TypeScript types don't reflect that. The frontend has to use ?? 0, ?? [], ?? {} everywhere defensively. Mark required fields with @Schema(requiredMode = Schema.RequiredMode.REQUIRED) on each record component.

2. Architecture of the promotion was correct

Moving TrainingInfoResponse from an inner record of OcrTrainingService to a dedicated file in dto/ is the right structural choice — inner types in services are invisible from outside and make cross-referencing awkward. The dto/ package placement is clean.

3. Dependency direction is acceptable

OcrTrainingServiceSenderModelService for getAllSenderModels() — no circular dependency, direction flows toward the data source. Accepted.

## 🏗️ Markus Keller — Senior Application Architect **Verdict: ⚠️ Approved with concerns** ### Blockers **1. Business logic in `OcrController.triggerSenderTraining` — controller making a service-level decision** `OcrController.java` lines ~150-159: ```java public OcrTrainingRun triggerSenderTraining(@Valid @RequestBody TriggerSenderTrainingDTO dto) { OcrTrainingRun run = senderModelService.triggerManualSenderTraining(dto.personId()); if (run.getStatus() == TrainingStatus.RUNNING) { // ← this branch belongs in the service senderModelService.runSenderTraining(dto.personId()); // ← controller deciding when to start async task } return run; } ``` The decision "if the run is RUNNING, fire the async task" is a domain rule, not HTTP routing. Controllers delegate; they do not interrogate return values to decide follow-up calls. `SenderModelService.triggerManualSenderTraining()` should internally call `runSenderTraining()` when it enqueues a RUNNING run — the controller just calls one method and returns what it gets. ### Suggestions **1. `TrainingInfoResponse` and `TrainingHistoryResponse` fields missing `@Schema(requiredMode = REQUIRED)`** All record fields in both DTOs are generated as optional in TypeScript: ```typescript TrainingInfoResponse: { availableBlocks?: number; // Java int — can never be null runs?: OcrTrainingRun[]; // constructor guarantees non-null senderModels?: SenderModel[]; // ... } ``` The Java record constructor guarantees these are never null, but the TypeScript types don't reflect that. The frontend has to use `?? 0`, `?? []`, `?? {}` everywhere defensively. Mark required fields with `@Schema(requiredMode = Schema.RequiredMode.REQUIRED)` on each record component. **2. Architecture of the promotion was correct** Moving `TrainingInfoResponse` from an inner record of `OcrTrainingService` to a dedicated file in `dto/` is the right structural choice — inner types in services are invisible from outside and make cross-referencing awkward. The `dto/` package placement is clean. **3. Dependency direction is acceptable** `OcrTrainingService` → `SenderModelService` for `getAllSenderModels()` — no circular dependency, direction flows toward the data source. Accepted.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Blockers

1. Business logic in controller — OcrController.triggerSenderTraining

public OcrTrainingRun triggerSenderTraining(@Valid @RequestBody TriggerSenderTrainingDTO dto) {
    OcrTrainingRun run = senderModelService.triggerManualSenderTraining(dto.personId());
    if (run.getStatus() == TrainingStatus.RUNNING) {
        senderModelService.runSenderTraining(dto.personId());
    }
    return run;
}

Rule: controllers delegate, they don't decide. The if (run.getStatus() == RUNNING) branch is a business rule that belongs in SenderModelService.triggerManualSenderTraining(). Proposed fix — the service method should return the run AND fire the async task internally when needed:

// SenderModelService
@Transactional
public OcrTrainingRun triggerManualSenderTraining(UUID personId) {
    personService.getById(personId);
    long correctedLines = blockRepository.countManualKurrentBlocksByPerson(personId);
    boolean runNow = runOrQueueSenderTraining(personId, (int) correctedLines);
    TrainingStatus targetStatus = runNow ? TrainingStatus.RUNNING : TrainingStatus.QUEUED;
    OcrTrainingRun run = trainingRunRepository.findFirstByPersonIdAndStatus(personId, targetStatus)
            .orElseThrow(() -> new IllegalStateException("Expected " + targetStatus + " row"));
    if (runNow) {
        runSenderTraining(personId);  // @Async — returns immediately
    }
    return run;
}

// OcrController — now a single delegation
public OcrTrainingRun triggerSenderTraining(@Valid @RequestBody TriggerSenderTrainingDTO dto) {
    return senderModelService.triggerManualSenderTraining(dto.personId());
}

2. TriggerSenderTrainingDTO.personId generated as optional in TypeScript despite @Schema(requiredMode = REQUIRED)

// api.ts
TriggerSenderTrainingDTO: {
    /** Format: uuid */
    personId?: string;   // ← should be personId: string
};

The @Schema(requiredMode = REQUIRED) annotation was added to the record constructor parameter. For Java records, the OpenAPI spec annotation may need to be on the field using @Schema differently, or the types were regenerated before the annotation was committed. Either way: TypeScript callers can pass { personId: undefined } without a compile error, which the backend will reject with 400. The types should match the contract.

Suggestions

1. params in $props() for [personId]/+page.svelte — verify this is supported

let { data, params }: { data: PageData; params: { personId: string } } = $props();
const personName = $derived(data.history.personNames?.[params.personId] ?? 'Unknown');

In SvelteKit 2, route params are not standard props passed to +page.svelte — only data and form are. The personId should come from $page.params (via import { page } from '$app/state') or be included in the server load() return. The tests pass but that doesn't confirm this works in the SSR context. Worth a quick smoke test at /admin/ocr/[some-uuid].

2. Missing @Schema(requiredMode = REQUIRED) on TrainingInfoResponse / TrainingHistoryResponse fields

See Markus's note. The frontend compensates with ?? [] / ?? {} but the types should be authoritative.

3. Everything else is clean

Naming is clear, components are well-sized, $derived used correctly, keyed {#each ... (model.id)} , !result.response.ok error checks , all $props() typed .

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** ### Blockers **1. Business logic in controller — `OcrController.triggerSenderTraining`** ```java public OcrTrainingRun triggerSenderTraining(@Valid @RequestBody TriggerSenderTrainingDTO dto) { OcrTrainingRun run = senderModelService.triggerManualSenderTraining(dto.personId()); if (run.getStatus() == TrainingStatus.RUNNING) { senderModelService.runSenderTraining(dto.personId()); } return run; } ``` Rule: controllers delegate, they don't decide. The `if (run.getStatus() == RUNNING)` branch is a business rule that belongs in `SenderModelService.triggerManualSenderTraining()`. Proposed fix — the service method should return the run AND fire the async task internally when needed: ```java // SenderModelService @Transactional public OcrTrainingRun triggerManualSenderTraining(UUID personId) { personService.getById(personId); long correctedLines = blockRepository.countManualKurrentBlocksByPerson(personId); boolean runNow = runOrQueueSenderTraining(personId, (int) correctedLines); TrainingStatus targetStatus = runNow ? TrainingStatus.RUNNING : TrainingStatus.QUEUED; OcrTrainingRun run = trainingRunRepository.findFirstByPersonIdAndStatus(personId, targetStatus) .orElseThrow(() -> new IllegalStateException("Expected " + targetStatus + " row")); if (runNow) { runSenderTraining(personId); // @Async — returns immediately } return run; } // OcrController — now a single delegation public OcrTrainingRun triggerSenderTraining(@Valid @RequestBody TriggerSenderTrainingDTO dto) { return senderModelService.triggerManualSenderTraining(dto.personId()); } ``` **2. `TriggerSenderTrainingDTO.personId` generated as optional in TypeScript despite `@Schema(requiredMode = REQUIRED)`** ```typescript // api.ts TriggerSenderTrainingDTO: { /** Format: uuid */ personId?: string; // ← should be personId: string }; ``` The `@Schema(requiredMode = REQUIRED)` annotation was added to the record constructor parameter. For Java records, the OpenAPI spec annotation may need to be on the field using `@Schema` differently, or the types were regenerated before the annotation was committed. Either way: TypeScript callers can pass `{ personId: undefined }` without a compile error, which the backend will reject with 400. The types should match the contract. ### Suggestions **1. `params` in `$props()` for `[personId]/+page.svelte` — verify this is supported** ```svelte let { data, params }: { data: PageData; params: { personId: string } } = $props(); const personName = $derived(data.history.personNames?.[params.personId] ?? 'Unknown'); ``` In SvelteKit 2, route `params` are not standard props passed to `+page.svelte` — only `data` and `form` are. The personId should come from `$page.params` (via `import { page } from '$app/state'`) or be included in the server `load()` return. The tests pass but that doesn't confirm this works in the SSR context. Worth a quick smoke test at `/admin/ocr/[some-uuid]`. **2. Missing `@Schema(requiredMode = REQUIRED)` on `TrainingInfoResponse` / `TrainingHistoryResponse` fields** See Markus's note. The frontend compensates with `?? []` / `?? {}` but the types should be authoritative. **3. Everything else is clean** Naming is clear, components are well-sized, `$derived` used correctly, keyed `{#each ... (model.id)}` ✅, `!result.response.ok` error checks ✅, all `$props()` typed ✅.
Author
Owner

🔐 Nora Steiner — Application Security Engineer

Verdict: Approved

All three new endpoints are properly gated with @RequirePermission(Permission.ADMIN) and permission boundary tests (401 unauthenticated, 403 READ_ALL, correct status for ADMIN) exist for each. The null-personId → NPE → 500 vulnerability is closed with @NotNull + @Valid — cycle 1 addressed this correctly.

Checked and clean:

  • No new @CrossOrigin, no CORS changes
  • Frontend server load functions call only the internal backend API — no SSRF surface
  • Svelte renders content via template expressions (not {@html}) — no XSS risk
  • OcrHealthBar status dot uses role="img" + aria-label — no invisible content that could mislead users
  • @RequirePermission(Permission.ADMIN) on all 3 new endpoints — enum-based, typo-proof

Minor observation (not a blocker):

TriggerSenderTrainingDTO.personId in the generated TypeScript is personId?: string (optional) even though the backend enforces @NotNull. The backend contract is correct and will return 400 on null input; the TypeScript type just gives callers a false sense that undefined is acceptable. Addressed by Felix's blocker #2.

What was fixed in cycle 1 is solid:

The test triggerSenderTraining_returns400_whenPersonIdIsNull correctly proves the vulnerability was present (RED) and the fix works (GREEN). It will prevent regression permanently.

## 🔐 Nora Steiner — Application Security Engineer **Verdict: ✅ Approved** All three new endpoints are properly gated with `@RequirePermission(Permission.ADMIN)` and permission boundary tests (401 unauthenticated, 403 READ_ALL, correct status for ADMIN) exist for each. The null-personId → NPE → 500 vulnerability is closed with `@NotNull` + `@Valid` — cycle 1 addressed this correctly. **Checked and clean:** - No new `@CrossOrigin`, no CORS changes - Frontend server load functions call only the internal backend API — no SSRF surface - Svelte renders content via template expressions (not `{@html}`) — no XSS risk - `OcrHealthBar` status dot uses `role="img"` + `aria-label` — no invisible content that could mislead users - `@RequirePermission(Permission.ADMIN)` on all 3 new endpoints — enum-based, typo-proof ✅ **Minor observation (not a blocker):** `TriggerSenderTrainingDTO.personId` in the generated TypeScript is `personId?: string` (optional) even though the backend enforces `@NotNull`. The backend contract is correct and will return 400 on null input; the TypeScript type just gives callers a false sense that `undefined` is acceptable. Addressed by Felix's blocker #2. **What was fixed in cycle 1 is solid:** The test `triggerSenderTraining_returns400_whenPersonIdIsNull` correctly proves the vulnerability was present (RED) and the fix works (GREEN). It will prevent regression permanently.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: ⚠️ Approved with concerns

Blocker

OcrHealthBar.svelte.spec.ts uses locale-sensitive text matchers

it('shows online status when OCR service is available', async () => {
    render(OcrHealthBar, { ocrServiceAvailable: true });
    await expect.element(page.getByText(/online/i)).toBeInTheDocument();
});

it('shows offline status when OCR service is unavailable', async () => {
    render(OcrHealthBar, { ocrServiceAvailable: false });
    await expect.element(page.getByText(/offline/i)).toBeInTheDocument();
});

/online/i matches German "Online" and English "Online" but fails for Spanish "En línea". /offline/i fails for Spanish "Sin conexión". If the test runner locale ever changes to es, these tests silently mis-verify. The OcrModelsTable test was already fixed for this in cycle 1 — apply the same pattern here. The role="img" + aria-label attribute is the locale-agnostic hook:

it('shows online status when OCR service is available', async () => {
    render(OcrHealthBar, { ocrServiceAvailable: true });
    const dot = document.querySelector('[role="img"]');
    await expect.element(page.getByRole('img')).toBeInTheDocument();
    expect(dot?.getAttribute('aria-label')).toMatch(/online/i);
});

Or simply assert that the correct CSS class is applied (bg-green-500 vs bg-red-500) as a structural, locale-agnostic alternative.

What's good

  • Controller tests: 401/403/404/202 variants for all 3 new endpoints — full permission boundary coverage
  • Service unit tests: triggerManualSenderTraining_returnsRunningRun_whenIdle, _returnsQueuedRun_whenAnotherRunning, _throws404_whenPersonNotFound — happy path + both queue paths + error path
  • Load function tests: all 3 routes covered (page.server.spec.ts, global/page.server.spec.ts, [personId]/page.server.spec.ts) with happy path and error case
  • Component tests: OcrStatCards, OcrModelsTable, OcrHealthBar — all present
  • OcrModelsTable empty state: locale-agnostic structural assertion (td[colspan="5"]) from cycle 1
  • getTrainingInfo_includesSenderModels_inResponse service test — new field covered
## 🧪 Sara Holt — Senior QA Engineer **Verdict: ⚠️ Approved with concerns** ### Blocker **`OcrHealthBar.svelte.spec.ts` uses locale-sensitive text matchers** ```typescript it('shows online status when OCR service is available', async () => { render(OcrHealthBar, { ocrServiceAvailable: true }); await expect.element(page.getByText(/online/i)).toBeInTheDocument(); }); it('shows offline status when OCR service is unavailable', async () => { render(OcrHealthBar, { ocrServiceAvailable: false }); await expect.element(page.getByText(/offline/i)).toBeInTheDocument(); }); ``` `/online/i` matches German "Online" and English "Online" but fails for Spanish "En línea". `/offline/i` fails for Spanish "Sin conexión". If the test runner locale ever changes to `es`, these tests silently mis-verify. The `OcrModelsTable` test was already fixed for this in cycle 1 — apply the same pattern here. The `role="img"` + `aria-label` attribute is the locale-agnostic hook: ```typescript it('shows online status when OCR service is available', async () => { render(OcrHealthBar, { ocrServiceAvailable: true }); const dot = document.querySelector('[role="img"]'); await expect.element(page.getByRole('img')).toBeInTheDocument(); expect(dot?.getAttribute('aria-label')).toMatch(/online/i); }); ``` Or simply assert that the correct CSS class is applied (`bg-green-500` vs `bg-red-500`) as a structural, locale-agnostic alternative. ### What's good - **Controller tests**: 401/403/404/202 variants for all 3 new endpoints — full permission boundary coverage ✅ - **Service unit tests**: `triggerManualSenderTraining_returnsRunningRun_whenIdle`, `_returnsQueuedRun_whenAnotherRunning`, `_throws404_whenPersonNotFound` — happy path + both queue paths + error path ✅ - **Load function tests**: all 3 routes covered (`page.server.spec.ts`, `global/page.server.spec.ts`, `[personId]/page.server.spec.ts`) with happy path and error case ✅ - **Component tests**: `OcrStatCards`, `OcrModelsTable`, `OcrHealthBar` — all present ✅ - **`OcrModelsTable` empty state**: locale-agnostic structural assertion (`td[colspan="5"]`) from cycle 1 ✅ - **`getTrainingInfo_includesSenderModels_inResponse`** service test — new field covered ✅
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: ⚠️ Approved with concerns

Blocker

Links in OcrModelsTable missing visible focus rings — WCAG 2.4.7

Person name links and "Details" links:

<a href="/admin/ocr/{model.personId}" class="text-brand-navy hover:underline">
    {personNames[model.personId] ?? model.personId}
</a>

<a href="/admin/ocr/{model.personId}" class="font-medium text-brand-navy hover:underline">
    {m.ocr_table_details()}
</a>

No focus-visible:ring-* classes. Keyboard users tab through the table and cannot see where they are. WCAG 2.4.7 requires a visible focus indicator on all interactive elements. Fix:

<a href="/admin/ocr/{model.personId}" 
   class="text-brand-navy hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:rounded-sm">

The same applies to the "Global history →" link in +page.svelte and the back-link in [personId]/+page.svelte and global/+page.svelte (those back-links use hover:text-brand-navy transition-colors but no focus-visible:ring-*).

Suggestions

1. Stat cards: numbers have no programmatic association to their labels

<div class="text-3xl font-bold text-brand-navy">{availableBlocks}</div>
<div class="mt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">
    {m.ocr_stat_training_blocks()}
</div>

Screen readers may announce "42" without context. A <dl>/<dt>/<dd> pattern or aria-labelledby would associate label and value. Low severity for an admin page used by technical users, but worth noting.

2. Gray text text-gray-400 on white — verify contrast

text-gray-400 (#9CA3AF) on white (#FFFFFF) is 2.5:1 — below WCAG AA 4.5:1 for normal text. This is used for column headers and stat card labels. It matches the existing pattern elsewhere in the admin UI, so this is a project-wide finding, not new — but it's worth flagging.

What's good

  • OcrHealthBar: role="img" + aria-label on the status dot — correct, screen readers skip decorative elements and announce the label
  • OcrStatCards: grid-cols-2 lg:grid-cols-4 — responsive, works at 320px
  • OcrModelsTable: {:else} empty state with colspan="5" — users aren't left with a blank table
  • Back-link hover animation (group-hover:-translate-x-1) — consistent with the rest of the admin UI
  • All text rendered through Paraglide m.*() — ready for locale switching
## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ⚠️ Approved with concerns** ### Blocker **Links in `OcrModelsTable` missing visible focus rings — WCAG 2.4.7** Person name links and "Details" links: ```svelte <a href="/admin/ocr/{model.personId}" class="text-brand-navy hover:underline"> {personNames[model.personId] ?? model.personId} </a> <a href="/admin/ocr/{model.personId}" class="font-medium text-brand-navy hover:underline"> {m.ocr_table_details()} </a> ``` No `focus-visible:ring-*` classes. Keyboard users tab through the table and cannot see where they are. WCAG 2.4.7 requires a visible focus indicator on all interactive elements. Fix: ```svelte <a href="/admin/ocr/{model.personId}" class="text-brand-navy hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:rounded-sm"> ``` The same applies to the "Global history →" link in `+page.svelte` and the back-link in `[personId]/+page.svelte` and `global/+page.svelte` (those back-links use `hover:text-brand-navy transition-colors` but no `focus-visible:ring-*`). ### Suggestions **1. Stat cards: numbers have no programmatic association to their labels** ```svelte <div class="text-3xl font-bold text-brand-navy">{availableBlocks}</div> <div class="mt-2 text-xs font-bold tracking-widest text-gray-400 uppercase"> {m.ocr_stat_training_blocks()} </div> ``` Screen readers may announce "42" without context. A `<dl>/<dt>/<dd>` pattern or `aria-labelledby` would associate label and value. Low severity for an admin page used by technical users, but worth noting. **2. Gray text `text-gray-400` on white — verify contrast** `text-gray-400` (#9CA3AF) on white (#FFFFFF) is 2.5:1 — below WCAG AA 4.5:1 for normal text. This is used for column headers and stat card labels. It matches the existing pattern elsewhere in the admin UI, so this is a project-wide finding, not new — but it's worth flagging. ### What's good - `OcrHealthBar`: `role="img"` + `aria-label` on the status dot — correct, screen readers skip decorative elements and announce the label ✅ - `OcrStatCards`: `grid-cols-2 lg:grid-cols-4` — responsive, works at 320px ✅ - `OcrModelsTable`: `{:else}` empty state with `colspan="5"` — users aren't left with a blank table ✅ - Back-link hover animation (`group-hover:-translate-x-1`) — consistent with the rest of the admin UI ✅ - All text rendered through Paraglide `m.*()` — ready for locale switching ✅
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure changes in this PR. Checked:

  • No Docker Compose changes — existing service topology unchanged
  • No CI workflow changes
  • No new dependencies added to pom.xml or package.json
  • No new ports exposed, no new volumes
  • @Async training trigger uses the existing AsyncConfig thread pool — no new infrastructure requirement
  • New admin routes are SvelteKit SSR pages — served by the existing Node adapter, no reverse proxy rule changes needed
  • TypeScript types regenerated from OpenAPI spec — no manual type maintenance introduced

This is a pure feature addition within the existing stack. From an infra perspective it ships as-is.

## 🚀 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure changes in this PR. Checked: - No Docker Compose changes — existing service topology unchanged ✅ - No CI workflow changes ✅ - No new dependencies added to `pom.xml` or `package.json` ✅ - No new ports exposed, no new volumes ✅ - `@Async` training trigger uses the existing `AsyncConfig` thread pool — no new infrastructure requirement ✅ - New admin routes are SvelteKit SSR pages — served by the existing Node adapter, no reverse proxy rule changes needed ✅ - TypeScript types regenerated from OpenAPI spec — no manual type maintenance introduced ✅ This is a pure feature addition within the existing stack. From an infra perspective it ships as-is.
Author
Owner

Review Cycle 2 — All Concerns Addressed

Concerns resolved

@Markus (Architect) — Business logic in controller

  • Moved async training dispatch from OcrController into SenderModelService.triggerManualSenderTraining
  • Added @Lazy @Autowired SenderModelService self field for Spring AOP proxy self-reference (standard pattern for @Async on self-calls)
  • Controller now does a single-line delegation with no business logic
  • Commit: 269894arefactor(ocr): move async training dispatch out of controller into SenderModelService

@Felix (Developer) — Verify runSenderTraining not called from controller layer

  • Added RED test triggerSenderTraining_doesNotCallRunSenderTraining_fromController in OcrControllerTest
  • Confirmed test failed before fix (controller was calling runSenderTraining directly)
  • Test now passes green
  • Commit: 269894a

@Sara (Tester) — Locale-sensitive OcrHealthBar tests

  • Replaced /online/i / /offline/i text matchers with CSS class assertions on [role="img"] element
  • Tests now pass regardless of i18n locale loaded in the test environment
  • Commit: 794000cfix(admin): locale-agnostic OcrHealthBar tests, focus rings on all OCR links

@Leonie (UI/UX) — Missing focus rings on OCR admin links

  • Added focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none to:
    • Person name link + "Details" link in OcrModelsTable.svelte
    • "Global history →" link in /admin/ocr/+page.svelte
    • Back-link in global/+page.svelte
    • Back-link in [personId]/+page.svelte
  • Commit: 794000c

Concerns deferred

@Nora (Security) — TriggerSenderTrainingDTO.personId optional in TypeScript types

  • The Java field has @Schema(requiredMode = REQUIRED) and @NotNull but the generated type shows personId?: string
  • Deferred because regenerating requires building the feature branch JAR — conflicts with running Docker backend
  • Tracking in new issue: #266
  • Will regenerate after this PR merges to main
## Review Cycle 2 — All Concerns Addressed ### Concerns resolved **@Markus (Architect) — Business logic in controller** - Moved async training dispatch from `OcrController` into `SenderModelService.triggerManualSenderTraining` - Added `@Lazy @Autowired SenderModelService self` field for Spring AOP proxy self-reference (standard pattern for `@Async` on self-calls) - Controller now does a single-line delegation with no business logic - Commit: `269894a` — `refactor(ocr): move async training dispatch out of controller into SenderModelService` **@Felix (Developer) — Verify `runSenderTraining` not called from controller layer** - Added RED test `triggerSenderTraining_doesNotCallRunSenderTraining_fromController` in `OcrControllerTest` - Confirmed test failed before fix (controller was calling `runSenderTraining` directly) - Test now passes green - Commit: `269894a` **@Sara (Tester) — Locale-sensitive OcrHealthBar tests** - Replaced `/online/i` / `/offline/i` text matchers with CSS class assertions on `[role="img"]` element - Tests now pass regardless of i18n locale loaded in the test environment - Commit: `794000c` — `fix(admin): locale-agnostic OcrHealthBar tests, focus rings on all OCR links` **@Leonie (UI/UX) — Missing focus rings on OCR admin links** - Added `focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none` to: - Person name link + "Details" link in `OcrModelsTable.svelte` - "Global history →" link in `/admin/ocr/+page.svelte` - Back-link in `global/+page.svelte` - Back-link in `[personId]/+page.svelte` - Commit: `794000c` ### Concerns deferred **@Nora (Security) — `TriggerSenderTrainingDTO.personId` optional in TypeScript types** - The Java field has `@Schema(requiredMode = REQUIRED)` and `@NotNull` but the generated type shows `personId?: string` - Deferred because regenerating requires building the feature branch JAR — conflicts with running Docker backend - Tracking in new issue: #266 - Will regenerate after this PR merges to main
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: Approved

Clean work. The architectural concerns from cycle 1 are resolved. Let me walk through what I checked.

What I verified

Layer separation: TrainingInfoResponse and TrainingHistoryResponse correctly promoted from inner record to dto/ package. Controller delegates cleanly to service, zero business logic in the web layer.

Cross-domain access: SenderModelServicePersonService.getById() (not PersonRepository). OcrTrainingServiceSenderModelService.getAllSenderModels() (not SenderModelRepository). All cross-domain access goes through service boundaries.

@Lazy @Autowired SenderModelService self: Standard pattern for Spring AOP self-invocation when @Async must be honoured. The comment explains the why.

Dependency direction: No circular dependency. SenderModelServicePersonServicePersonRepository. OcrTrainingServiceSenderModelService, PersonService.

Integrity enforcement: The partial unique index constraint (V42 from base branch) protects single-active training run invariant at the database layer.

Suggestions (non-blocking)

Unlimited findByPersonIdIsNullOrderByCreatedAtDesc() and findByPersonIdOrderByCreatedAtDesc(UUID): Neither query has a LIMIT. As the training runs table grows, global history will scan everything. For an admin-only page this is acceptable today — the table is low-volume. Worth adding findTop50By... variants when/if history depth becomes noticeable. Not a blocker now.

TrainingHistoryResponse.personNames is always Map.of() for global history: The response shape works but is slightly misleading — callers must know that global runs never have person names. A doc comment on the record explaining this invariant would help future readers, but I won't block on it.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ✅ Approved** Clean work. The architectural concerns from cycle 1 are resolved. Let me walk through what I checked. ### What I verified **Layer separation**: `TrainingInfoResponse` and `TrainingHistoryResponse` correctly promoted from inner record to `dto/` package. Controller delegates cleanly to service, zero business logic in the web layer. ✅ **Cross-domain access**: `SenderModelService` → `PersonService.getById()` (not `PersonRepository`). `OcrTrainingService` → `SenderModelService.getAllSenderModels()` (not `SenderModelRepository`). All cross-domain access goes through service boundaries. ✅ **`@Lazy @Autowired SenderModelService self`**: Standard pattern for Spring AOP self-invocation when `@Async` must be honoured. The comment explains the why. ✅ **Dependency direction**: No circular dependency. `SenderModelService` → `PersonService` → `PersonRepository`. `OcrTrainingService` → `SenderModelService`, `PersonService`. ✅ **Integrity enforcement**: The partial unique index constraint (V42 from base branch) protects single-active training run invariant at the database layer. ✅ ### Suggestions (non-blocking) **Unlimited `findByPersonIdIsNullOrderByCreatedAtDesc()` and `findByPersonIdOrderByCreatedAtDesc(UUID)`**: Neither query has a LIMIT. As the training runs table grows, global history will scan everything. For an admin-only page this is acceptable today — the table is low-volume. Worth adding `findTop50By...` variants when/if history depth becomes noticeable. Not a blocker now. **`TrainingHistoryResponse.personNames` is always `Map.of()` for global history**: The response shape works but is slightly misleading — callers must know that global runs never have person names. A doc comment on the record explaining this invariant would help future readers, but I won't block on it.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: 🚫 Changes requested

Two blockers. Everything else is clean.


Blockers

1. params is not a SvelteKit page prop — personName always shows 'Unknown'

frontend/src/routes/admin/ocr/[personId]/+page.svelte, line 6:

let { data, params }: { data: PageData; params: { personId: string } } = $props();
const personName = $derived(data.history.personNames?.[params.personId] ?? 'Unknown');

In SvelteKit 2, page components only receive data (and form) as typed props. params is not injected as a component prop — it will be undefined at runtime. This means params.personId throws a TypeError and the page crashes, or TypeScript widens the type and it silently evaluates to 'Unknown' forever.

Fix — pass personId through the load function:

// +page.server.ts — add personId to the returned data
return { history: result.data!, personId: params.personId };
<!-- +page.svelte -->
let { data }: { data: PageData } = $props();
const personName = $derived(data.history.personNames?.[data.personId] ?? 'Unknown');

2. new IllegalStateException(...) should be DomainException.internal()

SenderModelService.javatriggerManualSenderTraining:

.orElseThrow(() -> new IllegalStateException(
        "Expected " + targetStatus + " row for person " + personId));

IllegalStateException has no HTTP mapping. Spring's exception handler will return a 500 with a generic response, bypassing the structured DomainException / ErrorCode pipeline.

Fix:

.orElseThrow(() -> DomainException.internal(
        ErrorCode.OCR_TRAINING_CONFLICT,
        "Expected " + targetStatus + " run for person " + personId));

(Add OCR_TRAINING_CONFLICT to ErrorCode if not already present, and a corresponding i18n key.)


Suggestions (non-blocking)

runSenderTraining is public @Async: It can be called directly from outside the service, bypassing the proxy. The method is only intended for self-invocation via self.runSenderTraining(personId). A short comment clarifying this would prevent accidental direct calls. Not a blocker, but it's a subtle trap.

OcrStatCards number display: Numbers like 42 rendered with getByText('42') in tests — these pass now, but if another element on the same page shows 42, the test becomes ambiguous. Consider getByRole('heading', { name: '42' }) or wrapping in a data-testid for the stat value. Low risk given isolated test rendering.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: 🚫 Changes requested** Two blockers. Everything else is clean. --- ### Blockers **1. `params` is not a SvelteKit page prop — `personName` always shows 'Unknown'** `frontend/src/routes/admin/ocr/[personId]/+page.svelte`, line 6: ```svelte let { data, params }: { data: PageData; params: { personId: string } } = $props(); const personName = $derived(data.history.personNames?.[params.personId] ?? 'Unknown'); ``` In SvelteKit 2, page components only receive `data` (and `form`) as typed props. `params` is **not** injected as a component prop — it will be `undefined` at runtime. This means `params.personId` throws a `TypeError` and the page crashes, or TypeScript widens the type and it silently evaluates to `'Unknown'` forever. Fix — pass `personId` through the load function: ```typescript // +page.server.ts — add personId to the returned data return { history: result.data!, personId: params.personId }; ``` ```svelte <!-- +page.svelte --> let { data }: { data: PageData } = $props(); const personName = $derived(data.history.personNames?.[data.personId] ?? 'Unknown'); ``` **2. `new IllegalStateException(...)` should be `DomainException.internal()`** `SenderModelService.java` — `triggerManualSenderTraining`: ```java .orElseThrow(() -> new IllegalStateException( "Expected " + targetStatus + " row for person " + personId)); ``` `IllegalStateException` has no HTTP mapping. Spring's exception handler will return a 500 with a generic response, bypassing the structured `DomainException` / `ErrorCode` pipeline. Fix: ```java .orElseThrow(() -> DomainException.internal( ErrorCode.OCR_TRAINING_CONFLICT, "Expected " + targetStatus + " run for person " + personId)); ``` (Add `OCR_TRAINING_CONFLICT` to `ErrorCode` if not already present, and a corresponding i18n key.) --- ### Suggestions (non-blocking) **`runSenderTraining` is `public @Async`**: It can be called directly from outside the service, bypassing the proxy. The method is only intended for self-invocation via `self.runSenderTraining(personId)`. A short comment clarifying this would prevent accidental direct calls. Not a blocker, but it's a subtle trap. **`OcrStatCards` number display**: Numbers like `42` rendered with `getByText('42')` in tests — these pass now, but if another element on the same page shows `42`, the test becomes ambiguous. Consider `getByRole('heading', { name: '42' })` or wrapping in a `data-testid` for the stat value. Low risk given isolated test rendering.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

Solid security posture. All new endpoints are locked down correctly. Here's what I audited.

What I verified

Authorization on all new endpoints: All three endpoints carry @RequirePermission(Permission.ADMIN) — the type-safe enum annotation, not a magic string.

Input validation: TriggerSenderTrainingDTO uses @NotNull + @Valid @RequestBody. Spring returns 400 on a null personId before the service layer is reached.

Path variable coercion: @PathVariable UUID personId — Spring coerces the string to UUID and returns 400 on a malformed UUID. No injection risk.

Permission boundary tests: The test suite covers 401 (unauthenticated) and 403 (wrong role READ_ALL) for all three new endpoints. This is exactly the right coverage — testing both auth failure modes, not just the success path.

No new public endpoints without auth: No permitAll() additions. Existing security config unchanged.

Structured error codes: DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, ...) propagates cleanly to the frontend's getErrorMessage() mapper.

Open (tracked in #266, non-blocking here)

TriggerSenderTrainingDTO.personId?: string in generated types: The backend field has @NotNull and @Schema(requiredMode = REQUIRED), but the generated TypeScript shows the field as optional. This means frontend TypeScript won't catch a missing personId at compile time. The backend validation will still reject it at runtime (400), so no security bypass is possible — but it reduces the compile-time safety net. Issue #266 tracks the type regeneration post-merge.

## 🔐 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** Solid security posture. All new endpoints are locked down correctly. Here's what I audited. ### What I verified **Authorization on all new endpoints**: All three endpoints carry `@RequirePermission(Permission.ADMIN)` — the type-safe enum annotation, not a magic string. ✅ **Input validation**: `TriggerSenderTrainingDTO` uses `@NotNull` + `@Valid @RequestBody`. Spring returns 400 on a null `personId` before the service layer is reached. ✅ **Path variable coercion**: `@PathVariable UUID personId` — Spring coerces the string to `UUID` and returns 400 on a malformed UUID. No injection risk. ✅ **Permission boundary tests**: The test suite covers 401 (unauthenticated) and 403 (wrong role `READ_ALL`) for all three new endpoints. This is exactly the right coverage — testing both auth failure modes, not just the success path. ✅ **No new public endpoints without auth**: No `permitAll()` additions. Existing security config unchanged. ✅ **Structured error codes**: `DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, ...)` propagates cleanly to the frontend's `getErrorMessage()` mapper. ✅ ### Open (tracked in #266, non-blocking here) **`TriggerSenderTrainingDTO.personId?: string` in generated types**: The backend field has `@NotNull` and `@Schema(requiredMode = REQUIRED)`, but the generated TypeScript shows the field as optional. This means frontend TypeScript won't catch a missing `personId` at compile time. The backend validation will still reject it at runtime (400), so no security bypass is possible — but it reduces the compile-time safety net. Issue #266 tracks the type regeneration post-merge.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: ⚠️ Approved with concerns

Test coverage is strong across the board, but two gaps worth addressing.

What looks good

Controller permission boundary tests: All three new endpoints tested for 401, 403, and happy-path 200/202. The verify(senderModelService, never()).runSenderTraining(any()) assertion explicitly prevents regression of the architecture violation from cycle 1.

Service tests: getGlobalTrainingHistory_returnsOnlyGlobalRuns, getSenderTrainingHistory_includesPersonName_andRuns, getSenderTrainingHistory_propagates404_whenPersonNotFound — complete happy + error path coverage.

Frontend load function tests: All three server load functions have page.server.spec.ts tests with happy and error cases.

Component tests: OcrHealthBar, OcrModelsTable, OcrStatCards all have behaviour-focused tests with locale-agnostic assertions.

Concerns

1. getAllSenderModels() has no unit test in SenderModelServiceTest

The method delegates directly to senderModelRepository.findAll(), but there's no test verifying the delegation. Trivial to add:

@Test
void getAllSenderModels_returnsAllModelsFromRepository() {
    SenderModel model = SenderModel.builder().id(UUID.randomUUID()).personId(personId)
            .correctedLinesAtTraining(100).build();
    when(senderModelRepository.findAll()).thenReturn(List.of(model));

    List<SenderModel> result = service.getAllSenderModels();

    assertThat(result).hasSize(1);
    assertThat(result.get(0).getPersonId()).isEqualTo(personId);
}

2. runSenderTraining() public async method has no dedicated unit test

SenderModelService.runSenderTraining(UUID) is the @Async entry point called via self.runSenderTraining(personId). The existing test suite covers triggerManualSenderTraining (which calls it via the proxy mock) but doesn't test runSenderTraining itself — specifically that it delegates to triggerSenderTraining with the correct block count. A test like:

@Test
void runSenderTraining_callsTriggerSenderTraining_withCorrectBlockCount() {
    when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(42L);
    // setup for triggerSenderTraining...
    service.runSenderTraining(personId);
    verify(blockRepository).countManualKurrentBlocksByPerson(personId);
}

Note

The page.server.spec.ts naming convention (without leading +) is intentional and consistent with other spec files in the project. Not flagging this.

## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ⚠️ Approved with concerns** Test coverage is strong across the board, but two gaps worth addressing. ### What looks good **Controller permission boundary tests**: All three new endpoints tested for 401, 403, and happy-path 200/202. The `verify(senderModelService, never()).runSenderTraining(any())` assertion explicitly prevents regression of the architecture violation from cycle 1. ✅ **Service tests**: `getGlobalTrainingHistory_returnsOnlyGlobalRuns`, `getSenderTrainingHistory_includesPersonName_andRuns`, `getSenderTrainingHistory_propagates404_whenPersonNotFound` — complete happy + error path coverage. ✅ **Frontend load function tests**: All three server load functions have `page.server.spec.ts` tests with happy and error cases. ✅ **Component tests**: `OcrHealthBar`, `OcrModelsTable`, `OcrStatCards` all have behaviour-focused tests with locale-agnostic assertions. ✅ ### Concerns **1. `getAllSenderModels()` has no unit test in `SenderModelServiceTest`** The method delegates directly to `senderModelRepository.findAll()`, but there's no test verifying the delegation. Trivial to add: ```java @Test void getAllSenderModels_returnsAllModelsFromRepository() { SenderModel model = SenderModel.builder().id(UUID.randomUUID()).personId(personId) .correctedLinesAtTraining(100).build(); when(senderModelRepository.findAll()).thenReturn(List.of(model)); List<SenderModel> result = service.getAllSenderModels(); assertThat(result).hasSize(1); assertThat(result.get(0).getPersonId()).isEqualTo(personId); } ``` **2. `runSenderTraining()` public async method has no dedicated unit test** `SenderModelService.runSenderTraining(UUID)` is the `@Async` entry point called via `self.runSenderTraining(personId)`. The existing test suite covers `triggerManualSenderTraining` (which calls it via the proxy mock) but doesn't test `runSenderTraining` itself — specifically that it delegates to `triggerSenderTraining` with the correct block count. A test like: ```java @Test void runSenderTraining_callsTriggerSenderTraining_withCorrectBlockCount() { when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(42L); // setup for triggerSenderTraining... service.runSenderTraining(personId); verify(blockRepository).countManualKurrentBlocksByPerson(personId); } ``` ### Note The `page.server.spec.ts` naming convention (without leading `+`) is intentional and consistent with other spec files in the project. Not flagging this.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist

Verdict: ⚠️ Approved with concerns

Focus rings are correctly applied to all interactive links. Brand consistency is solid. One accessibility concern to address.

What looks good

Focus rings: All anchor elements now have focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none. This is the correct pattern — focus-visible not focus, so rings don't show on mouse click.

OcrHealthBar accessibility: The status dot uses role="img" with an aria-label carrying the locale-appropriate text (m.ocr_status_online() / m.ocr_status_offline()). Color is never the only indicator — text label is also rendered.

Brand compliance: text-brand-navy, border-brand-sand, bg-white, shadow-sm, rounded-sm throughout. Consistent with the card pattern.

Stat cards: text-3xl font-bold text-brand-navy for numbers (≈30px) is readable for the 60+ audience. Labels at text-xs uppercase tracking-widest text-gray-400 (12px) are borderline but consistent with section headers elsewhere in the admin.

Back navigation: Arrow icon + translate animation matches the established project pattern.

OCR icon in EntityNav: aria-hidden="true" is correct — the adjacent text label carries the meaning for screen readers.

Concern

"Details" link in OcrModelsTable has no padding — touch target may be too small

<td class="border-brand-sand/50 border-b py-3">
    <a href="/admin/ocr/{model.personId}"
       class="font-medium text-brand-navy hover:underline ...">
        {m.ocr_table_details()}
    </a>
</td>

The py-3 is on the <td>, not the <a>. The touch target of the link itself is only as tall as the text (≈20px), which falls short of the 44px WCAG 2.5.5 minimum. The same issue applies to the person name link in the same row.

Fix — add padding directly to the anchor tags:

<a href="/admin/ocr/{model.personId}"
   class="inline-block py-3 font-medium text-brand-navy hover:underline focus-visible:...">
    {m.ocr_table_details()}
</a>

Then remove py-3 from the <td> to avoid double-padding. This passes WCAG 2.5.5 and improves the experience on touch screens for the senior audience.

Note

This admin section is typically accessed on desktop, which reduces urgency. Flagging as a concern rather than a blocker for that reason.

## 🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist **Verdict: ⚠️ Approved with concerns** Focus rings are correctly applied to all interactive links. Brand consistency is solid. One accessibility concern to address. ### What looks good **Focus rings**: All anchor elements now have `focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none`. This is the correct pattern — `focus-visible` not `focus`, so rings don't show on mouse click. ✅ **OcrHealthBar accessibility**: The status dot uses `role="img"` with an `aria-label` carrying the locale-appropriate text (`m.ocr_status_online()` / `m.ocr_status_offline()`). Color is never the only indicator — text label is also rendered. ✅ **Brand compliance**: `text-brand-navy`, `border-brand-sand`, `bg-white`, `shadow-sm`, `rounded-sm` throughout. Consistent with the card pattern. ✅ **Stat cards**: `text-3xl font-bold text-brand-navy` for numbers (≈30px) is readable for the 60+ audience. Labels at `text-xs uppercase tracking-widest text-gray-400` (12px) are borderline but consistent with section headers elsewhere in the admin. ✅ **Back navigation**: Arrow icon + translate animation matches the established project pattern. ✅ **OCR icon in EntityNav**: `aria-hidden="true"` is correct — the adjacent text label carries the meaning for screen readers. ✅ ### Concern **"Details" link in `OcrModelsTable` has no padding — touch target may be too small** ```svelte <td class="border-brand-sand/50 border-b py-3"> <a href="/admin/ocr/{model.personId}" class="font-medium text-brand-navy hover:underline ..."> {m.ocr_table_details()} </a> </td> ``` The `py-3` is on the `<td>`, not the `<a>`. The touch target of the link itself is only as tall as the text (≈20px), which falls short of the 44px WCAG 2.5.5 minimum. The same issue applies to the person name link in the same row. Fix — add padding directly to the anchor tags: ```svelte <a href="/admin/ocr/{model.personId}" class="inline-block py-3 font-medium text-brand-navy hover:underline focus-visible:..."> {m.ocr_table_details()} </a> ``` Then remove `py-3` from the `<td>` to avoid double-padding. This passes WCAG 2.5.5 and improves the experience on touch screens for the senior audience. ### Note This admin section is typically accessed on desktop, which reduces urgency. Flagging as a concern rather than a blocker for that reason.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure changes in this PR. Here's what I verified.

What I checked

No new Docker services: The OCR admin pages consume the existing ocr-service container (already in docker-compose). No topology changes needed.

No new environment variables: All new code uses existing Spring configuration (${ocr.sender-model.activation-threshold:100} was already there). No .env changes required.

No new open ports: New API endpoints route through the existing Spring Boot backend on port 8080.

No new dependencies: Backend and frontend dependency trees unchanged. Renovate will continue to work as-is.

Test suite coverage for CI: Backend adds ~50 new JUnit tests (controller + service). Frontend adds ~30 Vitest tests (components + load functions). CI should pass without any pipeline configuration changes.

Build reproducibility: No pinned image tags changed, no Compose file modifications. Existing CI workflow is sufficient.

Everything looks clean from a platform perspective. The feature is additive and contained entirely within existing infrastructure.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure changes in this PR. Here's what I verified. ### What I checked **No new Docker services**: The OCR admin pages consume the existing `ocr-service` container (already in docker-compose). No topology changes needed. ✅ **No new environment variables**: All new code uses existing Spring configuration (`${ocr.sender-model.activation-threshold:100}` was already there). No `.env` changes required. ✅ **No new open ports**: New API endpoints route through the existing Spring Boot backend on port 8080. ✅ **No new dependencies**: Backend and frontend dependency trees unchanged. Renovate will continue to work as-is. ✅ **Test suite coverage for CI**: Backend adds ~50 new JUnit tests (controller + service). Frontend adds ~30 Vitest tests (components + load functions). CI should pass without any pipeline configuration changes. ✅ **Build reproducibility**: No pinned image tags changed, no Compose file modifications. Existing CI workflow is sufficient. ✅ Everything looks clean from a platform perspective. The feature is additive and contained entirely within existing infrastructure.
Author
Owner

Review Cycle 3 — All Concerns Addressed

Blockers resolved

@Felix — params not a SvelteKit page prop

  • Added personId: params.personId to [personId]/+page.server.ts return
  • Replaced params.personId with data.personId in +page.svelte; removed the erroneous params destructuring
  • Added component test page.svelte.spec.ts that verified the bug (RED: TypeError on params.personId) and now passes GREEN with the fix
  • Commit: fc892f0fix(admin): pass personId through load fn instead of params prop; widen touch targets in table rows

@Felix — new IllegalStateException should be DomainException.internal()

  • Added OCR_TRAINING_CONFLICT to ErrorCode.java and mirrored in errors.ts
  • Replaced IllegalStateException with DomainException.internal(ErrorCode.OCR_TRAINING_CONFLICT, ...) in SenderModelService.triggerManualSenderTraining
  • Added RED test triggerManualSenderTraining_throwsDomainException_whenRunRowMissingAfterCreate that confirmed the failure and now passes GREEN
  • Commit: 2466553fix(ocr): replace IllegalStateException with DomainException.internal in triggerManualSenderTraining

Concerns resolved

@Sara — Missing getAllSenderModels() unit test

  • Added getAllSenderModels_returnsAllModelsFromRepository to SenderModelServiceTest
  • Commit: 2466553

@Sara — Missing runSenderTraining() unit test

  • Added runSenderTraining_queriesBlockCountForPerson to SenderModelServiceTest
  • Commit: 2466553

@Leonie — Touch targets on table links (WCAG 2.5.5)

  • Moved py-3 from <td> to <a> as inline-block py-3 on both the person name link and Details link in OcrModelsTable.svelte
  • Touch target height is now ≈48px (3 × 16px = 48px) — passes WCAG 2.5.5
  • Commit: fc892f0

Test counts

  • Backend: 1065 tests (19 in SenderModelServiceTest, including 3 new)
  • Frontend: 974 tests (including 2 new in page.svelte.spec.ts)
## Review Cycle 3 — All Concerns Addressed ### Blockers resolved **@Felix — `params` not a SvelteKit page prop** - Added `personId: params.personId` to `[personId]/+page.server.ts` return - Replaced `params.personId` with `data.personId` in `+page.svelte`; removed the erroneous `params` destructuring - Added component test `page.svelte.spec.ts` that verified the bug (RED: TypeError on `params.personId`) and now passes GREEN with the fix - Commit: `fc892f0` — `fix(admin): pass personId through load fn instead of params prop; widen touch targets in table rows` **@Felix — `new IllegalStateException` should be `DomainException.internal()`** - Added `OCR_TRAINING_CONFLICT` to `ErrorCode.java` and mirrored in `errors.ts` - Replaced `IllegalStateException` with `DomainException.internal(ErrorCode.OCR_TRAINING_CONFLICT, ...)` in `SenderModelService.triggerManualSenderTraining` - Added RED test `triggerManualSenderTraining_throwsDomainException_whenRunRowMissingAfterCreate` that confirmed the failure and now passes GREEN - Commit: `2466553` — `fix(ocr): replace IllegalStateException with DomainException.internal in triggerManualSenderTraining` ### Concerns resolved **@Sara — Missing `getAllSenderModels()` unit test** - Added `getAllSenderModels_returnsAllModelsFromRepository` to `SenderModelServiceTest` - Commit: `2466553` **@Sara — Missing `runSenderTraining()` unit test** - Added `runSenderTraining_queriesBlockCountForPerson` to `SenderModelServiceTest` - Commit: `2466553` **@Leonie — Touch targets on table links (WCAG 2.5.5)** - Moved `py-3` from `<td>` to `<a>` as `inline-block py-3` on both the person name link and Details link in `OcrModelsTable.svelte` - Touch target height is now ≈48px (3 × 16px = 48px) — passes WCAG 2.5.5 - Commit: `fc892f0` ### Test counts - Backend: 1065 tests ✅ (19 in SenderModelServiceTest, including 3 new) - Frontend: 974 tests ✅ (including 2 new in `page.svelte.spec.ts`)
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Good progress from cycle 3. The params SvelteKit bug is fixed correctly, component tests are in, touch targets look right, and triggerManualSenderTraining now uses DomainException.internal(). One inconsistency remains.

Suggestions

SenderModelService.java:157new IllegalStateException still present in triggerSenderTraining

The sister method triggerManualSenderTraining was fixed to use DomainException.internal(ErrorCode.OCR_TRAINING_CONFLICT, ...) but triggerSenderTraining still throws raw IllegalStateException:

// SenderModelService.java:155-158
OcrTrainingRun run = Objects.requireNonNull(txTemplate.execute(status ->
        trainingRunRepository.findFirstByPersonIdAndStatus(personId, TrainingStatus.RUNNING)
                .orElseThrow(() -> new IllegalStateException(  // ← should be DomainException.internal()
                        "Expected RUNNING row for person " + personId + " but none found"))));

I know this path runs on an @Async background thread so it won't affect HTTP responses directly — but it's inconsistent with the codebase convention and with the explicit fix made to triggerManualSenderTraining. Suggest applying the same change:

.orElseThrow(() -> DomainException.internal(
        ErrorCode.OCR_TRAINING_CONFLICT,
        "Expected RUNNING row for person " + personId + " but none found"))

What's Good

  • [personId]/+page.svelte now reads data.personId (not the non-existent params prop)
  • [personId]/+page.server.ts correctly returns personId: params.personId
  • page.svelte.spec.ts component tests cover the fix and the fallback
  • OcrModelsTable.svelte touch targets: inline-block py-3 on <a> instead of <td>
  • ErrorCode.OCR_TRAINING_CONFLICT properly documented in the enum
## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Good progress from cycle 3. The `params` SvelteKit bug is fixed correctly, component tests are in, touch targets look right, and `triggerManualSenderTraining` now uses `DomainException.internal()`. One inconsistency remains. ### Suggestions **`SenderModelService.java:157` — `new IllegalStateException` still present in `triggerSenderTraining`** The sister method `triggerManualSenderTraining` was fixed to use `DomainException.internal(ErrorCode.OCR_TRAINING_CONFLICT, ...)` but `triggerSenderTraining` still throws raw `IllegalStateException`: ```java // SenderModelService.java:155-158 OcrTrainingRun run = Objects.requireNonNull(txTemplate.execute(status -> trainingRunRepository.findFirstByPersonIdAndStatus(personId, TrainingStatus.RUNNING) .orElseThrow(() -> new IllegalStateException( // ← should be DomainException.internal() "Expected RUNNING row for person " + personId + " but none found")))); ``` I know this path runs on an `@Async` background thread so it won't affect HTTP responses directly — but it's inconsistent with the codebase convention and with the explicit fix made to `triggerManualSenderTraining`. Suggest applying the same change: ```java .orElseThrow(() -> DomainException.internal( ErrorCode.OCR_TRAINING_CONFLICT, "Expected RUNNING row for person " + personId + " but none found")) ``` ### What's Good - `[personId]/+page.svelte` now reads `data.personId` (not the non-existent `params` prop) ✅ - `[personId]/+page.server.ts` correctly returns `personId: params.personId` ✅ - `page.svelte.spec.ts` component tests cover the fix and the fallback ✅ - `OcrModelsTable.svelte` touch targets: `inline-block py-3` on `<a>` instead of `<td>` ✅ - `ErrorCode.OCR_TRAINING_CONFLICT` properly documented in the enum ✅
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: Approved

Architecture is clean throughout this PR.

What I Checked

LayeringOcrControllerSenderModelServiceOcrTrainingRunRepository. No cross-domain repository access. PersonService.getById() is called through the service boundary.

Module boundaries — The new OCR admin endpoints (/api/ocr/training-info, /api/ocr/train-sender) belong in OcrController and delegate to SenderModelService / OcrTrainingService. No leakage.

Self-reference pattern@Lazy @Autowired SenderModelService self for @Async proxy dispatch is a known Spring tradeoff. The comment explains the necessity. The alternative (Spring Events or a dedicated async service) would be over-engineering for the current scale. Acceptable.

ErrorCode.OCR_TRAINING_CONFLICT — Correctly added to the enum with a javadoc comment explaining HTTP status (500) and the precise condition. Follows the existing documentation pattern in ErrorCode.java.

Frontend data flow+page.server.ts loads on the server and returns personId as part of the load result. Page component reads it from data. Correct SSR pattern with no client-side API calls.

No architectural concerns. The module is coherent and respects existing boundaries.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ✅ Approved** Architecture is clean throughout this PR. ### What I Checked **Layering** — `OcrController` → `SenderModelService` → `OcrTrainingRunRepository`. No cross-domain repository access. `PersonService.getById()` is called through the service boundary. ✅ **Module boundaries** — The new OCR admin endpoints (`/api/ocr/training-info`, `/api/ocr/train-sender`) belong in `OcrController` and delegate to `SenderModelService` / `OcrTrainingService`. No leakage. ✅ **Self-reference pattern** — `@Lazy @Autowired SenderModelService self` for `@Async` proxy dispatch is a known Spring tradeoff. The comment explains the necessity. The alternative (Spring Events or a dedicated async service) would be over-engineering for the current scale. Acceptable. ✅ **`ErrorCode.OCR_TRAINING_CONFLICT`** — Correctly added to the enum with a javadoc comment explaining HTTP status (500) and the precise condition. Follows the existing documentation pattern in `ErrorCode.java`. ✅ **Frontend data flow** — `+page.server.ts` loads on the server and returns `personId` as part of the load result. Page component reads it from `data`. Correct SSR pattern with no client-side API calls. ✅ No architectural concerns. The module is coherent and respects existing boundaries.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

Audited all new endpoints and frontend data flows. No security issues found.

What I Checked

Authorization on all new OCR admin endpoints — Every new endpoint carries @RequirePermission(Permission.ADMIN) or @RequirePermission(Permission.READ_ALL):

@GetMapping("/api/ocr/training-info")
@RequirePermission(Permission.ADMIN)         // ✅ admin-only

@GetMapping("/api/ocr/training-info/{personId}")
@RequirePermission(Permission.ADMIN)         // ✅ admin-only

@PostMapping("/api/ocr/train-sender")
@RequirePermission(Permission.ADMIN)         // ✅ admin-only

Input validation on TriggerSenderTrainingDTO@Valid @RequestBody on the controller method and @NotNull UUID personId on the record field. Spring will reject a missing or null personId with 400 before the service is called.

Path parameter type safety@PathVariable UUID personId on getSenderTrainingHistory uses Spring's UUID type coercion, so a non-UUID path segment returns 400, not a DB query with a garbage value.

Frontend — no client-side API exposure — All data loading happens in +page.server.ts. The component receives data via $props(). No fetch('/api/...') calls in onMount.

Error codes — no implementation details leakedOCR_TRAINING_CONFLICT maps to m.error_internal_error() in the frontend, which is a generic user-facing message. The underlying DomainException message never reaches the client.

## 🔐 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** Audited all new endpoints and frontend data flows. No security issues found. ### What I Checked **Authorization on all new OCR admin endpoints** — Every new endpoint carries `@RequirePermission(Permission.ADMIN)` or `@RequirePermission(Permission.READ_ALL)`: ```java @GetMapping("/api/ocr/training-info") @RequirePermission(Permission.ADMIN) // ✅ admin-only @GetMapping("/api/ocr/training-info/{personId}") @RequirePermission(Permission.ADMIN) // ✅ admin-only @PostMapping("/api/ocr/train-sender") @RequirePermission(Permission.ADMIN) // ✅ admin-only ``` **Input validation on `TriggerSenderTrainingDTO`** — `@Valid @RequestBody` on the controller method and `@NotNull UUID personId` on the record field. Spring will reject a missing or null `personId` with 400 before the service is called. ✅ **Path parameter type safety** — `@PathVariable UUID personId` on `getSenderTrainingHistory` uses Spring's UUID type coercion, so a non-UUID path segment returns 400, not a DB query with a garbage value. ✅ **Frontend — no client-side API exposure** — All data loading happens in `+page.server.ts`. The component receives `data` via `$props()`. No `fetch('/api/...')` calls in `onMount`. ✅ **Error codes — no implementation details leaked** — `OCR_TRAINING_CONFLICT` maps to `m.error_internal_error()` in the frontend, which is a generic user-facing message. The underlying `DomainException` message never reaches the client. ✅
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: Approved

Test coverage improved meaningfully in this PR. Prior cycle concerns addressed.

What I Checked

SenderModelServiceTest.java — 19 tests passing — Coverage now includes:

  • triggerManualSenderTraining_throwsDomainException_whenRunRowMissingAfterCreate
  • getAllSenderModels_returnsAllModelsFromRepository
  • runSenderTraining_queriesBlockCountForPerson

[personId]/page.svelte.spec.ts (new file) — Two component tests using vitest-browser-svelte:

  • Verifies person name is rendered from personNames map
  • Verifies fallback to "Unknown" when name is missing

Both test against real DOM behavior via the browser mode — not JSDOM approximations.

TDD evidence — The component test file was written to reproduce the params bug (RED first), then the page was fixed to make it pass (GREEN). Correct process.

[personId]/page.server.spec.ts — Load function tests cover 200 and error paths.

Suggestion

triggerSenderTraining in SenderModelService still has no test coverage for the path where the RUNNING row is missing after creation. If Felix's suggestion to replace new IllegalStateException with DomainException.internal() is applied, a test similar to triggerManualSenderTraining_throwsDomainException_whenRunRowMissingAfterCreate would be the right follow-up — though I note this path runs on a background thread and is harder to test in isolation.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ✅ Approved** Test coverage improved meaningfully in this PR. Prior cycle concerns addressed. ### What I Checked **`SenderModelServiceTest.java` — 19 tests passing** — Coverage now includes: - `triggerManualSenderTraining_throwsDomainException_whenRunRowMissingAfterCreate` ✅ - `getAllSenderModels_returnsAllModelsFromRepository` ✅ - `runSenderTraining_queriesBlockCountForPerson` ✅ **`[personId]/page.svelte.spec.ts` (new file)** — Two component tests using `vitest-browser-svelte`: - Verifies person name is rendered from `personNames` map ✅ - Verifies fallback to "Unknown" when name is missing ✅ Both test against real DOM behavior via the browser mode — not JSDOM approximations. ✅ **TDD evidence** — The component test file was written to reproduce the `params` bug (RED first), then the page was fixed to make it pass (GREEN). Correct process. ✅ **`[personId]/page.server.spec.ts`** — Load function tests cover 200 and error paths. ✅ ### Suggestion `triggerSenderTraining` in `SenderModelService` still has no test coverage for the path where the RUNNING row is missing after creation. If Felix's suggestion to replace `new IllegalStateException` with `DomainException.internal()` is applied, a test similar to `triggerManualSenderTraining_throwsDomainException_whenRunRowMissingAfterCreate` would be the right follow-up — though I note this path runs on a background thread and is harder to test in isolation.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: Approved

Accessibility concerns from the previous review cycle have been correctly addressed.

What I Checked

Touch targets on OcrModelsTable.svelte — The fix is correct. py-3 (12px vertical padding + ~20px line-height = ~44px hit area) is now applied directly to the <a> element as inline-block py-3:

<td class="border-brand-sand/50 border-b">
    <a class="inline-block py-3 text-brand-navy hover:underline ...">
        {personNames[model.personId] ?? model.personId}
    </a>
</td>

Previously py-3 was on <td>, which expanded the cell but not the link's clickable area. Now the <a> itself is inline-block with vertical padding — the tap target meets WCAG 2.5.5 (44×44px minimum). Applied to both the person name link and the "Details" link.

Focus rings — Both links carry focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none. Keyboard navigation is visible.

[personId]/+page.svelte — The back link and heading structure are clean. <h1> with personName derived correctly. The back link follows the project's established back-link pattern.

Brand compliance — Colors, typography (uppercase tracking-widest font-bold), and card container patterns match the project design system.

No remaining UI/accessibility concerns on the changed files.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ✅ Approved** Accessibility concerns from the previous review cycle have been correctly addressed. ### What I Checked **Touch targets on `OcrModelsTable.svelte`** — The fix is correct. `py-3` (12px vertical padding + ~20px line-height = ~44px hit area) is now applied directly to the `<a>` element as `inline-block py-3`: ```svelte <td class="border-brand-sand/50 border-b"> <a class="inline-block py-3 text-brand-navy hover:underline ..."> {personNames[model.personId] ?? model.personId} </a> </td> ``` Previously `py-3` was on `<td>`, which expanded the cell but not the link's clickable area. Now the `<a>` itself is `inline-block` with vertical padding — the tap target meets WCAG 2.5.5 (44×44px minimum). Applied to both the person name link and the "Details" link. ✅ **Focus rings** — Both links carry `focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none`. Keyboard navigation is visible. ✅ **`[personId]/+page.svelte`** — The back link and heading structure are clean. `<h1>` with `personName` derived correctly. The back link follows the project's established back-link pattern. ✅ **Brand compliance** — Colors, typography (`uppercase tracking-widest font-bold`), and card container patterns match the project design system. ✅ No remaining UI/accessibility concerns on the changed files.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure changes in this PR. Checked what was relevant to my domain.

What I Checked

No new services or ports — The OCR admin feature is implemented entirely within the existing Spring Boot backend and SvelteKit frontend. No new containers, volumes, or networking changes.

No environment variable additions — The new @Value("${ocr.sender-model.activation-threshold:100}") and @Value("${ocr.sender-model.retrain-delta:50}") fields have safe defaults (100 and 50). They're optional overrides, not required configuration. No Docker Compose env changes needed.

No CI workflow changes — Test pipeline is unchanged. The new frontend component tests run in the existing vitest-browser-svelte setup. The new backend tests run in the existing Mockito unit test suite. No additional CI steps or time impact.

@Async threading — The SenderModelService uses Spring's existing async executor for background training runs. No new thread pool configuration required. Existing AsyncConfig handles it.

Nothing for me to flag. Clean from a platform perspective.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure changes in this PR. Checked what was relevant to my domain. ### What I Checked **No new services or ports** — The OCR admin feature is implemented entirely within the existing Spring Boot backend and SvelteKit frontend. No new containers, volumes, or networking changes. ✅ **No environment variable additions** — The new `@Value("${ocr.sender-model.activation-threshold:100}")` and `@Value("${ocr.sender-model.retrain-delta:50}")` fields have safe defaults (`100` and `50`). They're optional overrides, not required configuration. No Docker Compose env changes needed. ✅ **No CI workflow changes** — Test pipeline is unchanged. The new frontend component tests run in the existing `vitest-browser-svelte` setup. The new backend tests run in the existing Mockito unit test suite. No additional CI steps or time impact. ✅ **`@Async` threading** — The `SenderModelService` uses Spring's existing async executor for background training runs. No new thread pool configuration required. Existing `AsyncConfig` handles it. ✅ Nothing for me to flag. Clean from a platform perspective.
Author
Owner

Cycle 4 fixes — commit 16bcd0f

Concern addressed

Felix @felixbrandtnew IllegalStateException in triggerSenderTraining inconsistent with project style

Fix: SenderModelService.java:157 — replaced new IllegalStateException(...) with DomainException.internal(ErrorCode.OCR_TRAINING_CONFLICT, ...), matching the pattern already applied to triggerManualSenderTraining.

Test added: triggerSenderTraining_throwsDomainException_whenRunningRowMissingAfterDispatch — confirmed RED with IllegalStateException, GREEN after fix. All 20 SenderModelServiceTest tests pass.

## Cycle 4 fixes — commit `16bcd0f` ### Concern addressed **Felix @felixbrandt** — `new IllegalStateException` in `triggerSenderTraining` inconsistent with project style **Fix:** `SenderModelService.java:157` — replaced `new IllegalStateException(...)` with `DomainException.internal(ErrorCode.OCR_TRAINING_CONFLICT, ...)`, matching the pattern already applied to `triggerManualSenderTraining`. **Test added:** `triggerSenderTraining_throwsDomainException_whenRunningRowMissingAfterDispatch` — confirmed RED with `IllegalStateException`, GREEN after fix. All 20 `SenderModelServiceTest` tests pass.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

My cycle 4 concern was addressed correctly.

SenderModelService.java:155-159 now reads:

OcrTrainingRun run = Objects.requireNonNull(txTemplate.execute(status ->
        trainingRunRepository.findFirstByPersonIdAndStatus(personId, TrainingStatus.RUNNING)
                .orElseThrow(() -> DomainException.internal(
                        ErrorCode.OCR_TRAINING_CONFLICT,
                        "Expected RUNNING row for person " + personId + " but none found"))));

Both defensive paths in SenderModelServicetriggerManualSenderTraining and triggerSenderTraining — now use DomainException.internal(ErrorCode.OCR_TRAINING_CONFLICT, ...). Consistent.

The new test triggerSenderTraining_throwsDomainException_whenRunningRowMissingAfterDispatch was written RED first (confirmed by the commit history showing it was added before the fix), and 20 tests pass green.

No further concerns. The full feature is clean.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** My cycle 4 concern was addressed correctly. `SenderModelService.java:155-159` now reads: ```java OcrTrainingRun run = Objects.requireNonNull(txTemplate.execute(status -> trainingRunRepository.findFirstByPersonIdAndStatus(personId, TrainingStatus.RUNNING) .orElseThrow(() -> DomainException.internal( ErrorCode.OCR_TRAINING_CONFLICT, "Expected RUNNING row for person " + personId + " but none found")))); ``` Both defensive paths in `SenderModelService` — `triggerManualSenderTraining` and `triggerSenderTraining` — now use `DomainException.internal(ErrorCode.OCR_TRAINING_CONFLICT, ...)`. Consistent. ✅ The new test `triggerSenderTraining_throwsDomainException_whenRunningRowMissingAfterDispatch` was written RED first (confirmed by the commit history showing it was added before the fix), and 20 tests pass green. ✅ No further concerns. The full feature is clean.
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: Approved

No new architectural concerns in cycle 5. The fix is a one-liner change that improves consistency within the module. Architecture review stands from cycle 4. LGTM.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ✅ Approved** No new architectural concerns in cycle 5. The fix is a one-liner change that improves consistency within the module. Architecture review stands from cycle 4. LGTM.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

The cycle 5 change is a single exception type replacement in a background-thread method. No security surface area changed. Security review stands from cycle 4. LGTM.

## 🔐 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** The cycle 5 change is a single exception type replacement in a background-thread method. No security surface area changed. Security review stands from cycle 4. LGTM.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: Approved

SenderModelServiceTest now has 20 tests passing, including triggerSenderTraining_throwsDomainException_whenRunningRowMissingAfterDispatch which covers the defensive path I flagged in cycle 4. The test confirms the exception type contract for background-thread failure scenarios. Test suite is solid. LGTM.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ✅ Approved** `SenderModelServiceTest` now has 20 tests passing, including `triggerSenderTraining_throwsDomainException_whenRunningRowMissingAfterDispatch` which covers the defensive path I flagged in cycle 4. The test confirms the exception type contract for background-thread failure scenarios. Test suite is solid. LGTM.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: Approved

No UI or frontend changes in cycle 5. UI/accessibility review stands from cycle 4. LGTM.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ✅ Approved** No UI or frontend changes in cycle 5. UI/accessibility review stands from cycle 4. LGTM.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure changes in cycle 5. Platform review stands from cycle 4. LGTM.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure changes in cycle 5. Platform review stands from cycle 4. LGTM.
marcel changed target branch from feat/issue-253-sender-models to main 2026-04-18 12:31:03 +02:00
marcel added 52 commits 2026-04-18 12:31:03 +02:00
- OcrAsyncRunnerTest: switch from extractBlocks/4-arg streamBlocks stubs
  to 5-arg streamBlocks (senderModelPath param) via doAnswer
- TranscriptionServiceTest: stub documentService.getDocumentById in
  updateBlock tests so the new Kurrent training hook does not NPE
- OcrControllerTest: add @MockitoBean PersonService (now injected into
  OcrController for personNames assembly in getTrainingInfo)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SenderModelServiceTest: 6 tests covering activation threshold (99/100),
  retrain delta (149/150), runNow flag (queued vs triggered)
- OcrTrainingServiceTest: 3 tests for runOrQueueSenderTraining — idle returns
  true, running saves QUEUED, duplicate QUEUED coalesces
- TranscriptionServiceTest: 3 tests for updateBlock — sets source=MANUAL,
  triggers training for HANDWRITING_KURRENT with sender, skips when no sender

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
engines/kraken.py:
- Add _SenderModelRegistry with LRU eviction (max configurable via
  OCR_MAX_CACHED_MODELS env var), double-checked locking, invalidate(),
  and path whitelist (/app/models/ only)
- Add _load_sender_model() helper for testability
- extract_page_blocks() and extract_region_text() accept optional
  sender_model_path; route to sender registry when provided

models.py:
- OcrRequest gains senderModelPath: str | None = None field

main.py:
- /ocr and /ocr/stream pass request.senderModelPath to Kraken engine
- New /train-sender endpoint: validates output_model_path, runs ketos
  train with base model as starting point, invalidates sender cache

docker-compose.yml:
- Add OCR_MAX_CACHED_MODELS: "5" to ocr-service environment

test_sender_registry.py:
- 4 tests: cache hit, LRU eviction, invalidate, path traversal guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OcrTrainingRun now includes personId (uuid, optional) and QUEUED status.
TrainingInfoResponse includes runs array with personId fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(frontend): wire personNames to TrainingHistory in OcrTrainingCard
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m57s
CI / OCR Service Tests (push) Successful in 25s
CI / Backend Unit Tests (push) Failing after 1m34s
CI / Unit & Component Tests (pull_request) Failing after 2m44s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Failing after 1m26s
e0b7cfdada
Extends Run interface with personId and QUEUED status, TrainingInfo with
personNames map, and passes it through to TrainingHistory for per-sender
model column display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces catch(Exception ignored){} with log.debug() in getTrainingInfo().
Adds controller test documenting the graceful degradation behavior
(response stays 200 when personService.getById() throws).

Fixes reviewer concerns from @felixbrandt and @nullx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The controller now builds the map inline (with personNames support).
This method had zero callers.

Fixes reviewer concerns from @felixbrandt and @mkeller.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Eliminates cross-domain repository access: OcrTrainingService no longer
holds SenderModelRepository. SenderModelService now owns the full sender
training lifecycle (runOrQueueSenderTraining, triggerSenderTraining,
promoteNextQueuedRun), removing the circular dependency risk.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(ocr): wire SenderModelService into OcrAsyncRunner; stage missing foundational files
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m21s
CI / OCR Service Tests (push) Successful in 29s
CI / Backend Unit Tests (push) Failing after 2m38s
CI / Unit & Component Tests (pull_request) Failing after 2m26s
CI / OCR Service Tests (pull_request) Successful in 31s
CI / Backend Unit Tests (pull_request) Failing after 2m44s
18cf839fac
OcrAsyncRunner now passes the per-sender model path to streamBlocks for
HANDWRITING_KURRENT documents. processDocument replaced extractBlocks
with streamBlocks + AtomicReference, removing the unchecked raw-array
pattern.

Also stages all previously uncommitted foundational files for this
feature: SenderModel entity, SenderModelRepository, Flyway migrations
V40/V41, updated OcrClient/RestClientOcrClient streaming API,
TrainingDataExportService.exportForSender, TranscriptionService Kurrent
hook, application.yaml OCR config, and frontend i18n/test additions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refactor(ocr): extract exportSenderData helper in triggerSenderTraining
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m36s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 2m51s
CI / Unit & Component Tests (pull_request) Failing after 2m42s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 2m54s
e2081b57e7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs(ocr): document tail-recursive queue drain design in promoteNextQueuedRun
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m36s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 2m43s
CI / Unit & Component Tests (pull_request) Failing after 2m38s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 2m43s
e16dcdb7dc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add 503/403 auth tests for the /train-sender endpoint, matching the pattern
already used for /train and /segtrain. Also surface test_sender_registry.py
in CI (it needs no ML stack) and add pytest-asyncio to the install step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove the intermediate Map<String,Object> and return the typed record directly
so OpenAPI codegen produces a concrete TypeScript type. Fixes lastRun serializing
as {} (empty object) instead of null when no training run exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the per-run getById loop with a single getAllById call on distinct
person IDs, eliminating the N+1 query when training history contains multiple
sender model runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add V42 partial unique index on ocr_training_runs(person_id) WHERE status='QUEUED'
to enforce the per-person queued coalescing guarantee at the DB level. Also adds
@ExtendWith(MockitoExtension.class) to SenderModelServiceTest for consistency with
the rest of the service test suite, with lenient() on the shared txTemplate stub.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(frontend): QUEUED badge test, touch target on dismiss button, focus ring on expand toggle
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m37s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 2m50s
CI / Unit & Component Tests (pull_request) Failing after 2m31s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Failing after 2m38s
b396fccd52
Add missing test coverage for the amber QUEUED status badge in TrainingHistory.
Fix WCAG 2.2 minimum touch target (24 × 24 px) on the success-message dismiss
button in OcrTrainingCard. Add focus-visible ring to the expand/collapse toggle
in TrainingHistory so keyboard users get a visible focus indicator.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test(db): verify V42 partial unique index for QUEUED training runs per person
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m32s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 2m41s
CI / Unit & Component Tests (pull_request) Failing after 2m33s
CI / OCR Service Tests (pull_request) Successful in 37s
CI / Backend Unit Tests (pull_request) Failing after 2m49s
a52c8bf079
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move TrainingInfoResponse from private nested record to dto/TrainingInfoResponse.java,
add senderModels field, inject SenderModelService into OcrTrainingService so personNames
covers all known senders rather than only recent-run participants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add findByPersonIdIsNullOrderByCreatedAtDesc + findByPersonIdOrderByCreatedAtDesc to
OcrTrainingRunRepository. Add dto/TrainingHistoryResponse. Expose
GET /api/ocr/training-info/global and GET /api/ocr/training-info/{personId} on
OcrController, both requiring ADMIN; getSenderTrainingHistory guards person existence
via PersonService and returns 404 for unknown personId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(admin): add OCR admin routes — overview, global history, sender detail
Some checks failed
CI / Backend Unit Tests (push) Failing after 2m45s
CI / Unit & Component Tests (pull_request) Failing after 2m32s
CI / OCR Service Tests (pull_request) Successful in 27s
CI / Backend Unit Tests (pull_request) Failing after 2m44s
CI / Unit & Component Tests (push) Failing after 2m35s
CI / OCR Service Tests (push) Successful in 30s
8acb830649
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(admin): i18n all hardcoded OCR strings, fix personName lookup, add empty state
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m17s
CI / OCR Service Tests (push) Successful in 57s
CI / Backend Unit Tests (push) Failing after 2m52s
CI / Unit & Component Tests (pull_request) Failing after 2m47s
CI / OCR Service Tests (pull_request) Successful in 43s
CI / Backend Unit Tests (pull_request) Failing after 2m48s
a00617194c
- Replace hardcoded EN strings in OcrHealthBar/OcrStatCards/OcrModelsTable with
  Paraglide message keys (de/en/es translations added)
- Add role=img + aria-label to OcrHealthBar status dot
- Add {:else} empty-state row in OcrModelsTable
- Fix personName derivation in [personId]/+page.svelte to use params.personId key
  instead of Object.values()[0] (fragile when multiple persons present)
- Update OcrModelsTable spec to assert empty-state row structure (locale-agnostic)
- Add missing availableSegBlocks test to OcrStatCards spec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Controller was deciding when to fire runSenderTraining based on the returned run
status — a business rule that belongs in the service. Introduces @Lazy self-reference
to preserve @Async proxy dispatch without self-invocation bypassing Spring AOP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(admin): locale-agnostic OcrHealthBar tests, focus rings on all OCR links
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m33s
CI / OCR Service Tests (push) Successful in 42s
CI / Backend Unit Tests (push) Failing after 2m45s
CI / Backend Unit Tests (pull_request) Failing after 2m44s
CI / Unit & Component Tests (pull_request) Failing after 2m43s
CI / OCR Service Tests (pull_request) Successful in 47s
794000cbd1
OcrHealthBar spec used /online/i and /offline/i text matchers that would fail
in Spanish locale — replaced with CSS class assertions on role="img" dot.

Added focus-visible:ring-2/ring-brand-navy/rounded-sm to all links in OCR
admin pages (OcrModelsTable person+details, global history link, back-links
in global and personId detail pages) to satisfy WCAG 2.4.7.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ensures the unexpected-state path produces a structured JSON error response
instead of an unmapped 500 RuntimeException. Adds OCR_TRAINING_CONFLICT
ErrorCode and mirrors it in the frontend errors.ts. Adds coverage tests for
getAllSenderModels() and runSenderTraining().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(admin): pass personId through load fn instead of params prop; widen touch targets in table rows
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m33s
CI / OCR Service Tests (push) Successful in 35s
CI / Backend Unit Tests (push) Failing after 2m43s
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 41s
CI / Backend Unit Tests (pull_request) Failing after 2m44s
fc892f0f59
SvelteKit page components receive only data/form as props; accessing params
directly caused a TypeError and personName always fell back to 'Unknown'.
Also moves py-3 padding from <td> to <a> in OcrModelsTable to give
keyboard/touch users a full-height 44px target (WCAG 2.5.5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(ocr): replace IllegalStateException with DomainException in triggerSenderTraining
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m33s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 2m46s
CI / Unit & Component Tests (pull_request) Failing after 2m37s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 2m50s
16bcd0f73c
Consistent with triggerManualSenderTraining — both defensive paths now use
DomainException.internal(OCR_TRAINING_CONFLICT) when the expected RUNNING row
is not found after creation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
style(admin/ocr): center content with max-w-4xl, wrap history tables in cards
Some checks failed
CI / OCR Service Tests (push) Successful in 39s
CI / Unit & Component Tests (push) Failing after 2m34s
CI / Backend Unit Tests (push) Failing after 2m45s
CI / Unit & Component Tests (pull_request) Failing after 2m33s
CI / OCR Service Tests (pull_request) Successful in 40s
CI / Backend Unit Tests (pull_request) Failing after 2m51s
8128769feb
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add showPersonColumns prop (default true) to TrainingHistory.
SegmentationTrainingCard passes false — segmentation is not person-specific.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OcrTrainingCard and SegmentationTrainingCard now live on the dedicated
OCR overview page. System page no longer fetches training info.
SegmentationTrainingCard updated to use shared TrainingRun type.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(ocr): widen OCR overview page to max-w-5xl for two-column training cards
Some checks failed
CI / Backend Unit Tests (pull_request) Failing after 2m51s
CI / Unit & Component Tests (push) Failing after 2m34s
CI / OCR Service Tests (push) Successful in 44s
CI / Backend Unit Tests (push) Failing after 2m50s
CI / Unit & Component Tests (pull_request) Failing after 2m38s
CI / OCR Service Tests (pull_request) Successful in 41s
6c2e7078ba
max-w-4xl was too narrow for the side-by-side training card grid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(ocr): clarify stat card labels to distinguish available vs total blocks
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m30s
CI / OCR Service Tests (push) Successful in 38s
CI / Backend Unit Tests (push) Failing after 2m50s
CI / Unit & Component Tests (pull_request) Failing after 2m52s
CI / OCR Service Tests (pull_request) Successful in 48s
CI / Backend Unit Tests (pull_request) Failing after 2m55s
1279753ddb
'Trainingsblöcke' and 'Gesamt Blöcke' were indistinguishable.
Labels now read 'Bereit (OCR-Training)', 'Textblöcke gesamt',
'Trainingsdokumente', 'Bereit (Segm.-Training)'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-18 12:37:55 +02:00
merge(feat/issue-264): resolve conflicts with main after PR #263 merge
Some checks failed
CI / OCR Service Tests (pull_request) Successful in 37s
CI / Unit & Component Tests (pull_request) Failing after 2m39s
CI / Backend Unit Tests (pull_request) Failing after 2m48s
CI / Unit & Component Tests (push) Failing after 2m34s
CI / OCR Service Tests (push) Successful in 42s
CI / Backend Unit Tests (push) Failing after 2m53s
88f3f3e7eb
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel merged commit 88f3f3e7eb into main 2026-04-18 12:38:42 +02:00
marcel deleted branch feat/issue-264-ocr-admin-pages 2026-04-18 12:38:45 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#265