feat(admin): OCR admin pages — overview & model detail #264
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Spec
UI/UX spec:
docs/specs/ocr-admin-spec.htmlCommit:
78eca8eTwo new admin routes:
/admin/ocr— health bar, stat cards, models table (global + all senders), global action buttons/admin/ocr/global— global model detail with metric cards + full training history/admin/ocr/[personId]— sender model detail with metric cards + full training history + person linkBackend gaps to close first
1 — Add sender models list to
GET /api/ocr/training-infoThe overview models table must show all known sender models, including those not in the last 20 runs. Currently the response only includes runs-derived data.
Changes:
senderModels: List<SenderModel>toTrainingInfoResponse(viaSenderModelRepository.findAll())SenderModelRepositoryintoOcrTrainingServicepersonNamesinOcrController.getTrainingInfo()to cover all senders, not just recent-run persons2 — Per-model history via
?personId=query paramDetail pages need the full run history for one model — top-20 global is insufficient once there are many senders and frequent global runs.
Changes to
OcrTrainingRunRepository:Change to
GET /api/ocr/training-info: add optional?personId=query param.?personId=global→ returns full global model history (personId IS NULL)?personId={uuid}→ returns full sender history for that person3 —
POST /api/ocr/train-senderendpointThe sender detail page has a "Jetzt neu trainieren" button. There is no HTTP endpoint exposing sender training today (
SenderModelService.triggerSenderTraining()is internal only).Add to
OcrController:No backend changes needed
availableBlocks,availableDocuments,availableSegBlocks,ocrServiceAvailable) ✅TrainingStatusenum (QUEUED / RUNNING / DONE / FAILED) ✅POST /api/ocr/trainandPOST /api/ocr/segtrain✅Frontend work (after backend)
EntityNav(new "System" section with separator; count =1 + senderModels.length)admin/ocr/+page.svelte,admin/ocr/global/+page.svelte,admin/ocr/[personId]/+page.svelteTrainingHistory.sveltecomponent on detail pages/api/ocr/jobs/{jobId}/progresschannelnpm run generate:api)🏗️ Markus Keller — Application Architect
Observations
Cross-domain repository access in the issue spec. Gap 1 explicitly says "Inject
SenderModelRepositoryintoOcrTrainingService." This violates the project's layering rule: each service owns its repository; cross-domain data access goes through the owning service, not its repository.SenderModelRepositorybelongs to the sender-model domain —OcrTrainingServicehas no business holding a direct reference to it.TrainingInfoResponseis a private nested record insideOcrTrainingService(lines 38–46). Once we addsenderModelsand expose the type via the controller, it needs to be a top-level class. A controller method should never implicitly reference a private nested record from a service.?personId=globalis a magic string in a UUID-typed parameter slot. This breaks the implicit type contract of the parameter, will confuse OpenAPI documentation (which will type it asstringrather thanuuid), and needs special-case parsing in the backend. Clean alternatives exist.Three routes sharing the same full-history shape (
/admin/ocr/globaland/admin/ocr/[personId]) is a good reuse ofTrainingHistory.svelte. This part of the design is sound.Recommendations
Fix the cross-domain access: Add
List<SenderModel> getAllSenderModels()toSenderModelServiceand call it fromOcrTrainingService.getTrainingInfo(). Never injectSenderModelRepositoryinto a service that doesn't own it.Promote
TrainingInfoResponsetodto/TrainingInfoResponse.java(or a top-level record in theservicepackage if you prefer). It's already controller-visible — a private nested type is structurally wrong for that role.Replace
?personId=globalwith path-based routing. The cleanest option:GET /api/ocr/training-info(overview),GET /api/ocr/training-info/global(full global history),GET /api/ocr/training-info/{personId}(sender history). Path segments make the intent explicit and the OpenAPI type can beuuidfor the personId variant. If a single endpoint with a query param is preferred, use two separate params:?scope=globalvs?personId={uuid}— not a string-that-pretends-to-be-a-UUID.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
runOrQueueSenderTraining(UUID personId, int correctedLines)requirescorrectedLines, but the new endpoint body only haspersonId. The issue spec says "callsSenderModelService.runOrQueueSenderTraining()" without addressing the second parameter. Passing0silently is not obvious — it needs a deliberate decision.HTTP 201 is wrong for an async trigger. The existing
POST /api/ocr/trainandPOST /api/ocr/segtrainreturn theOcrTrainingRunentity with an implicit 200. The newPOST /api/ocr/train-senderqueues or starts work — it doesn't create a resource. 202 Accepted with theOcrTrainingRunbody is the correct status and matches the async intent.senderModelsonTrainingInfoResponsewill benullon the overview path for all existing frontend consumers until the backend change lands andnpm run generate:apiis re-run. The TypeScript type will need to reflect this assenderModels?: SenderModel[]or be required once the backend ships. Plan the regeneration step before writing any frontend overview code.Component granularity for the three new pages. The issue mentions "health bar + models table are new components." Following the one-visual-region-per-file rule, I'd name these:
OcrHealthBar.svelte,OcrModelsTable.svelte,OcrStatCards.svelte. The detail pages (global/+page.svelte,[personId]/+page.svelte) should be under 60 lines each by composing these.TrainingHistory.sveltereuse is correct as-is — its props{ runs: Run[], personNames?: Record<string, string> }already cover the detail page use case. No modification needed.Recommendations
Add
triggerManualSenderTraining(UUID personId): OcrTrainingRuntoSenderModelService— a thin wrapper that callsrunOrQueueSenderTraining(personId, 0)with documented intent. The0forcorrectedLinesbecomes an explicit named concept rather than a silent magic number at the call site.Change the endpoint response to
ResponseEntity<OcrTrainingRun>returningResponseEntity.accepted().body(run)(HTTP 202), consistent with the async nature and distinguishable from a true resource creation.Before any frontend work: rebuild the backend JAR, start with
--spring.profiles.active=dev, runnpm run generate:api. Do this immediately after each backend gap is closed — don't batch all three gaps before regenerating.🔐 Nora "NullX" Steiner — Security Engineer
Observations
POST /api/ocr/train-senderis correctly scoped toADMIN. ✅ The@RequirePermission(Permission.ADMIN)annotation viaPermissionAspectwill enforce this at the AOP layer.UUID validation at the controller boundary. If
personIdin the request body is declared asUUID(Java type), Spring's Jackson deserializer will throw a 400 automatically on malformed input. ✅ But if it's declared asStringand converted manually, an invalid UUID produces an opaque 500. Declare itUUIDin the DTO.No person existence check is described in the spec. The issue says the endpoint "calls
SenderModelService.runOrQueueSenderTraining()" directly. If thepersonIdbelongs to a non-existent person, the behavior is undefined — potentially a 500 from a foreign key constraint, or silently training a phantom model. A structured 404 should come back instead.Duplicate training prevention is already handled.
runOrQueueSenderTrainingprevents duplicate queued runs per person via application-level check. ✅ This is the correct defense against rapid repeated clicks on "Jetzt neu trainieren."SSE permission parity. The existing
/api/ocr/jobs/{jobId}/progressrequiresREAD_ALL. ADMIN permission must imply READ_ALL in the permission model — confirm this before wiring the sender detail page's progress polling, since an ADMIN-only user who lacksREAD_ALLwould lose their SSE stream mid-training.The
?personId=globalsentinel (from Gap 2). If this string is passed to any UUID-typed field, it will cause a 400 or 500. The controller must handle it with an explicitif ("global".equals(personId))branch — this is an untyped special case that lives entirely in application logic and is invisible to the type system or OpenAPI tooling.Recommendations
UUID personId(notString). Jackson validates format; you get a free 400 on malformed input.runOrQueueSenderTraining, callpersonService.getById(personId)— this returns a structuredDomainException.notFound(ErrorCode.PERSON_NOT_FOUND, ...)rather than a DB constraint error.READ_ALLas a second annotation where needed on the SSE endpoint.?personId=globaldesign survives (see Markus's concern), isolate the parsing in a dedicated method with a comment explaining the intent — don't scatter"global".equals(...)checks inline.🧪 Sara Holt — QA Engineer
Observations
Two new derived query methods (
findByPersonIdIsNullOrderByCreatedAtDesc(),findByPersonIdOrderByCreatedAtDesc(UUID personId)) look correct as Spring Data naming, but theIS NULLpath is non-obvious. Spring Data's null-handling for derived queries can surprise —findByPersonIdIsNullis explicit, but it's worth an integration test against real Postgres (not H2) to confirm the generated SQL matches expectations.The
?personId=query parameter has four distinct code paths that all need explicit test coverage:?personId=global→ full global history (personId IS NULL)?personId={valid uuid}→ full sender history?personId={invalid string}→ 400?personId={valid uuid, no matching person}→ 404 (if existence check is added)That's five
@WebMvcTestcases for a single endpoint change.POST /api/ocr/train-senderpermission boundary tests are not mentioned in the issue. These need to be written before the implementation (TDD): 401 unauthenticated, 403 for READ_ALL-only user, 404 for unknown personId, 202 for happy path.TrainingHistory.svelte.spec.ts— the existing spec tests coverruns+personNamesfor recent-run persons. OncepersonNamesis expanded to cover all senders (not just those in recent runs), add a test for the case where a person appears inpersonNamesbut not in the run list.runOrQueueSenderTrainingduplicate-prevention logic relies on application-level checks (not a DB unique constraint per the code). This is a race condition risk under concurrent requests. This path needs an integration test with Testcontainers and real Postgres to verify idempotency — a pure Mockito unit test cannot catch a TOCTOU race.Recommendations
@WebMvcTesttests for all five?personId=branches before implementing the endpoint change. The red test proves each branch is real.@SpringBootTest+ Testcontainers) for both new repository methods specifically verifying theIS NULL/IS NOT NULLdistinction against a realocr_training_runstable.CREATE UNIQUE INDEX ... WHERE status = 'QUEUED'per person) if concurrent admin use becomes realistic.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
The spec file
docs/specs/ocr-admin-spec.htmldoes not exist in the repository at the current commit. I cannot review the visual design against the spec — this review is based solely on the issue description."Health bar" component. A horizontal bar conveying health/availability status is an accessibility risk if it uses color alone (green/red). Color-blind users cannot distinguish healthy from degraded. The component also needs ARIA progressbar semantics if it shows a percentage.
count = 1 + senderModels.lengthin the EntityNav badge. A badge showing "OCR (5)" next to an admin nav item is ambiguous — does it mean 5 models? 5 senders? Unseen errors? The senior user segment won't parse this without context."Jetzt neu trainieren" button on the sender detail page. If a training run is QUEUED or RUNNING, the button must be visually disabled and communicate its state. Clicking a button and seeing nothing happen is confusing for all users, critical for seniors.
SSE progress updates. If the overview or detail page streams live training progress, the updating text must be in an
aria-liveregion so screen readers announce changes without requiring the user to navigate to the element.Three new admin routes — each needs a consistent
<title>(for browser tab and screen reader page announcements) and a back-link using the project's standard back-link pattern.Recommendations
Implement the health bar as:
Add an icon (✓ / ✗) alongside the color fill for color-blind parity.
Remove the numeric count from the EntityNav OCR entry, or use a subtitle:
"1 global + {n} Sender"as a descriptive label rather than a raw number badge.Bind "Jetzt neu trainieren" to
disabled={status === 'RUNNING' || status === 'QUEUED'}and show an inline spinner when active. Touch target:min-h-[44px].Wrap SSE-driven status text in:
Ensure each new route sets
<svelte:head><title>OCR — Familienarchiv</title></svelte:head>following the existing admin page pattern.🛠️ Tobias Wendt — DevOps & Platform Engineer
Observations
No new infrastructure components needed for this issue. No new Docker services, volumes, networks, or environment variables are introduced. ✅
OCR service memory limits. The admin "Jetzt neu trainieren" button puts model training directly in the hands of admins, potentially triggering it more frequently than the automated threshold-based path. If
docker-compose.ymldoesn't already havedeploy.resources.limits.memoryon the OCR service, concurrent or back-to-back sender training runs could OOM-kill the container (Surya loads ~5GB of transformer weights).SenderModelRepository.findAll()on every admin page load. The overview endpoint will callfindAll()on every visit to/admin/ocr. For a family archive with <30 senders this is negligible, but it's a full table scan with no pagination. Note it as a future concern if the archive grows.SSE stream timeout at the Caddy reverse proxy. The progress channel at
/api/ocr/jobs/{jobId}/progressis a long-lived HTTP connection. Caddy's defaultreverse_proxydirective uses a 0s (infinite) read timeout, but if the Caddyfile has explicit timeout overrides, the SSE stream will be silently cut mid-training. Verifyflush_interval -1is set for SSE routes, or that no explicit read timeout applies to the/api/ocr/jobs/*/progresspath.V40 and V41 migrations are already on the branch (confirmed from git status). No additional migrations needed for this issue.
Recommendations
docker-compose.ymlfor the OCR service — if there's nodeploy.resources.limits.memory, add8G(matching the Surya load requirement documented indocs/infrastructure/).flush_interval -1globally for SSE routes, or an explicithandle /api/ocr/jobs/*/progressblock with that setting. This is needed for the existing SSE consumers too — if it's not there yet, this is the right time to add it.🗳️ Decision Queue — Action Required
1 decision needs your input before implementation starts.
API Design
GET /api/ocr/training-info?personId=global(a magic string in a UUID slot) to distinguish global history from sender history. Markus recommends path segments (GET /api/ocr/training-info/globalandGET /api/ocr/training-info/{personId}) which give a clean OpenAPI type (uuidfor the personId variant) and explicit routing. Nora flagged that the?personId=globalapproach requires special-case parsing that's invisible to the type system. Trade-off: path segments require two controller methods and slightly different SvelteKit load function URLs; the sentinel query param keeps it as one method but conflates string and UUID semantics and makes OpenAPI generation awkward. (Raised by: Markus, Nora)🏗️ Markus Keller — Architecture Discussion
Four open items from my review comment worked through and closed. These supersede the Decision Queue item posted earlier.
Resolved
Cross-domain repo access — Do not inject
SenderModelRepositoryintoOcrTrainingService. AddgetAllSenderModels(): List<SenderModel>toSenderModelServiceand call that fromOcrTrainingService.getTrainingInfo(). Service-to-service is the permitted cross-domain channel.Routing design for per-model history — The
?personId=globalsentinel is dropped. Use three path-based endpoints instead:GET /api/ocr/training-info— overview (top-20 + senderModels list)GET /api/ocr/training-info/global— full global model historyGET /api/ocr/training-info/{personId}— full sender history (personIdtypeduuid)OpenAPI will type
personIdcorrectly and no sentinel parsing is needed in the controller.TrainingInfoResponselocation — Move from private nested record insideOcrTrainingServicetodto/TrainingInfoResponse.java(top-level class). It's controller-visible with a new field — a private nested type is the wrong home for it.POST /api/ocr/train-senderfull contract — Settled as:{ personId: UUID }— declare as JavaUUIDtype so Jackson rejects malformed input with a 400personService.getById(personId)beforeSenderModelService— returns structuredDomainException.notFound(PERSON_NOT_FOUND)rather than a FK constraint errorOcrTrainingRunbody (async trigger, not resource creation)The issue spec's Gap 1–3 descriptions should be read with these four corrections applied before implementation starts.
Implementation complete ✅
Branch:
feat/issue-264-ocr-admin-pagesCommits
Backend
b1b7418— PromoteTrainingInfoResponsetodto/, addsenderModelsfield178afcd— Add per-model history endpoints (GET /api/ocr/training-info/global,GET /api/ocr/training-info/{personId})c3fa09d— AddPOST /api/ocr/train-senderendpoint for manual sender training trigger99e7176— Service-level tests fortriggerManualSenderTrainingFrontend
f533817— Regenerate TypeScript API types with new OCR endpoints5f4e60a— Add OCR entry to EntityNav sidebar and flyout0d8ac46—OcrHealthBar,OcrStatCards,OcrModelsTablecomponents (with tests)8acb830— Admin OCR routes:/admin/ocroverview,/admin/ocr/globalhistory,/admin/ocr/[personId]sender detail (with page server tests)Test results
Implementation complete ✅
All acceptance criteria from the issue are implemented and fully reviewed via PR #265.
What was implemented
Backend
GET /api/ocr/models— returns all sender models withpersonId,cer,accuracy,correctedLinesAtTrainingGET /api/ocr/training-info— training overview (global stats, per-person model list)GET /api/ocr/training-info/global— global training history runsGET /api/ocr/training-info/{personId}— per-person training historyPOST /api/ocr/train-sender— manually trigger sender model training for a personErrorCode.OCR_TRAINING_CONFLICTadded for the defensive path intriggerManualSenderTrainingandtriggerSenderTraining@RequirePermission(Permission.ADMIN)SenderModelServiceTesttests passingFrontend
/admin/ocr— OCR overview page (health bar, stat cards, sender models table)/admin/ocr/global— global training history page/admin/ocr/[personId]— per-person training history pageOcrModelsTable,OcrStatCards,OcrHealthBar,TrainingHistorycomponentsCommits
794000c— locale-agnostic OcrHealthBar tests, focus rings on OCR links2466553— fix:IllegalStateException→DomainException.internalintriggerManualSenderTrainingfc892f0— fix: passpersonIdthrough load fn; widen touch targets in table rows16bcd0f— fix:IllegalStateException→DomainException.internalintriggerSenderTrainingPost-merge task
Issue #266 tracks regenerating TypeScript types so
TriggerSenderTrainingDTO.personIdbecomes non-optional in the generated client.PR: http://heim-nas:3005/marcel/familienarchiv/pulls/265