From 4e08d31e017ff0e8f724b93cd65adc014c9ce9a8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 13 Apr 2026 14:58:13 +0200 Subject: [PATCH] feat(admin): add OCR training card to admin/system page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TrainingHistory.svelte: responsive table with status badges (green/red/animated pulse), keyed iteration, empty-state row - OcrTrainingCard.svelte: shows available blocks/docs, disabled states (< 5 blocks, service down), in-flight "…" state, 5s success message, embeds TrainingHistory - Wired into admin/system/+page.svelte via fetchTrainingInfo() in $effect - Regenerated api.ts with OcrTrainingRun + TrainingInfoResponse types - TRAINING_ALREADY_RUNNING error code in errors.ts + de/en/es translations - 7 OcrTrainingCard Vitest tests Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + .../src/lib/components/OcrTrainingCard.svelte | 91 +++++++++ .../components/OcrTrainingCard.svelte.spec.ts | 96 +++++++++ .../src/lib/components/TrainingHistory.svelte | 76 +++++++ frontend/src/lib/errors.ts | 3 + frontend/src/lib/generated/api.ts | 189 +++++++++++++++++- frontend/src/routes/admin/system/+page.svelte | 17 ++ .../routes/admin/system/page.svelte.spec.ts | 2 + 10 files changed, 473 insertions(+), 4 deletions(-) create mode 100644 frontend/src/lib/components/OcrTrainingCard.svelte create mode 100644 frontend/src/lib/components/OcrTrainingCard.svelte.spec.ts create mode 100644 frontend/src/lib/components/TrainingHistory.svelte diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 2221634e..7040fb97 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -505,6 +505,7 @@ "error_ocr_job_not_found": "Der OCR-Auftrag wurde nicht gefunden.", "error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.", "error_ocr_processing_failed": "Die OCR-Verarbeitung ist fehlgeschlagen.", + "error_training_already_running": "Es läuft bereits ein Trainings-Vorgang.", "ocr_script_type_typewriter": "Schreibmaschine", "ocr_script_type_handwriting_latin": "Handschrift (lateinisch)", "ocr_script_type_handwriting_kurrent": "Handschrift (Kurrent/Sütterlin)", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8dcfb42e..2f299f0a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -505,6 +505,7 @@ "error_ocr_job_not_found": "The OCR job was not found.", "error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.", "error_ocr_processing_failed": "OCR processing failed.", + "error_training_already_running": "A training run is already in progress.", "ocr_script_type_typewriter": "Typewriter", "ocr_script_type_handwriting_latin": "Handwriting (Latin)", "ocr_script_type_handwriting_kurrent": "Handwriting (Kurrent/Sütterlin)", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 1737621b..f83c6159 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -505,6 +505,7 @@ "error_ocr_job_not_found": "No se encontró el trabajo OCR.", "error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.", "error_ocr_processing_failed": "El procesamiento OCR ha fallado.", + "error_training_already_running": "Ya hay un proceso de entrenamiento en curso.", "ocr_script_type_typewriter": "Máquina de escribir", "ocr_script_type_handwriting_latin": "Escritura manuscrita (latina)", "ocr_script_type_handwriting_kurrent": "Escritura manuscrita (Kurrent/Sütterlin)", diff --git a/frontend/src/lib/components/OcrTrainingCard.svelte b/frontend/src/lib/components/OcrTrainingCard.svelte new file mode 100644 index 00000000..040a1b14 --- /dev/null +++ b/frontend/src/lib/components/OcrTrainingCard.svelte @@ -0,0 +1,91 @@ + + +
+

Kurrent-Erkennung trainieren

+

+ Starte ein neues Training mit den bisher geprüften OCR-Blöcken, um die Erkennungsgenauigkeit für + Kurrentschrift zu verbessern. +

+ +

+ {available} geprüfte Blöcke bereit / + {trainingInfo?.availableDocuments ?? 0} Dokumente + (von {trainingInfo?.totalOcrBlocks ?? 0} OCR-Blöcken gesamt) +

+ + + + {#if tooFewBlocks} +

+ Mindestens 5 geprüfte Blöcke erforderlich (aktuell: {available}). +

+ {:else if serviceDown} +

OCR-Dienst ist nicht erreichbar.

+ {/if} + + {#if successMessage} +

{successMessage}

+ {/if} + +

Verlauf

+ +
diff --git a/frontend/src/lib/components/OcrTrainingCard.svelte.spec.ts b/frontend/src/lib/components/OcrTrainingCard.svelte.spec.ts new file mode 100644 index 00000000..a81a5227 --- /dev/null +++ b/frontend/src/lib/components/OcrTrainingCard.svelte.spec.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import OcrTrainingCard from './OcrTrainingCard.svelte'; + +afterEach(cleanup); +afterEach(() => vi.restoreAllMocks()); + +const baseInfo = { + availableBlocks: 10, + totalOcrBlocks: 20, + availableDocuments: 3, + ocrServiceAvailable: true, + lastRun: null, + runs: [] +}; + +describe('OcrTrainingCard — disabled states', () => { + it('disables button and shows hint when availableBlocks is 0', async () => { + render(OcrTrainingCard, { trainingInfo: { ...baseInfo, availableBlocks: 0 } }); + + const btn = page.getByRole('button', { name: /Training starten/i }); + await expect.element(btn).toBeDisabled(); + await expect + .element(page.getByText(/Mindestens 5 geprüfte Blöcke erforderlich/i)) + .toBeInTheDocument(); + }); + + it('disables button and shows hint when availableBlocks is less than 5', async () => { + render(OcrTrainingCard, { trainingInfo: { ...baseInfo, availableBlocks: 3 } }); + + const btn = page.getByRole('button', { name: /Training starten/i }); + await expect.element(btn).toBeDisabled(); + await expect.element(page.getByText(/Mindestens 5/i)).toBeInTheDocument(); + }); + + it('disables button and shows service-down warning when ocrServiceAvailable is false', async () => { + render(OcrTrainingCard, { trainingInfo: { ...baseInfo, ocrServiceAvailable: false } }); + + const btn = page.getByRole('button', { name: /Training starten/i }); + await expect.element(btn).toBeDisabled(); + await expect.element(page.getByText(/OCR-Dienst ist nicht erreichbar/i)).toBeInTheDocument(); + }); + + it('does not show service-down warning when blocks are insufficient', async () => { + // tooFewBlocks hint takes priority over serviceDown hint + render(OcrTrainingCard, { + trainingInfo: { ...baseInfo, availableBlocks: 2, ocrServiceAvailable: false } + }); + + await expect.element(page.getByText(/Mindestens 5/i)).toBeInTheDocument(); + // serviceDown text should NOT appear because tooFewBlocks branch hides it + const serviceMsg = document.querySelector('.text-orange-600'); + expect(serviceMsg).toBeNull(); + }); +}); + +describe('OcrTrainingCard — enabled state', () => { + it('enables button when availableBlocks >= 5 and service is up', async () => { + render(OcrTrainingCard, { trainingInfo: baseInfo }); + + const btn = page.getByRole('button', { name: /Training starten/i }); + await expect.element(btn).not.toBeDisabled(); + }); + + it('shows block count info text', async () => { + render(OcrTrainingCard, { + trainingInfo: { ...baseInfo, availableBlocks: 7, totalOcrBlocks: 15 } + }); + + await expect.element(page.getByText(/7/)).toBeInTheDocument(); + await expect.element(page.getByText(/von 15 OCR-Blöcken/i)).toBeInTheDocument(); + }); +}); + +describe('OcrTrainingCard — in-flight state', () => { + it('shows "…" while POST is in-flight', async () => { + let resolveFetch!: (v: unknown) => void; + const pendingFetch = new Promise((resolve) => { + resolveFetch = resolve; + }); + + vi.stubGlobal('fetch', vi.fn().mockReturnValue(pendingFetch)); + + render(OcrTrainingCard, { trainingInfo: baseInfo }); + + const btn = page.getByRole('button', { name: /Training starten/i }); + await btn.click(); + + // While fetch is still pending the button label becomes "…" + await expect.element(page.getByRole('button', { name: '…' })).toBeInTheDocument(); + + // Cleanup: resolve the pending promise + resolveFetch({ ok: false }); + }); +}); diff --git a/frontend/src/lib/components/TrainingHistory.svelte b/frontend/src/lib/components/TrainingHistory.svelte new file mode 100644 index 00000000..86f1361c --- /dev/null +++ b/frontend/src/lib/components/TrainingHistory.svelte @@ -0,0 +1,76 @@ + + + + + + + + + + + + + {#if runs.length === 0} + + + + {:else} + {#each runs as run (run.id)} + + + + + + + {/each} + {/if} + +
DatumStatusBlöcke
+ Noch keine Trainings-Läufe. +
{formatDate(run.createdAt)} + {#if run.status === 'DONE'} + ✓ Fertig + {:else if run.status === 'FAILED'} + ✗ Fehler + {:else} + Läuft… + {/if} + {run.blockCount}
diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index 1b8e8876..56073568 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -26,6 +26,7 @@ export type ErrorCode = | 'OCR_JOB_NOT_FOUND' | 'OCR_DOCUMENT_NOT_UPLOADED' | 'OCR_PROCESSING_FAILED' + | 'TRAINING_ALREADY_RUNNING' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'VALIDATION_ERROR' @@ -97,6 +98,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_ocr_document_not_uploaded(); case 'OCR_PROCESSING_FAILED': return m.error_ocr_processing_failed(); + case 'TRAINING_ALREADY_RUNNING': + return m.error_training_already_running(); case 'UNAUTHORIZED': return m.error_unauthorized(); case 'FORBIDDEN': diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 16961ab4..8e1963d1 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -228,6 +228,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/ocr/train": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["triggerTraining"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/ocr/batch": { parameters: { query?: never; @@ -564,6 +580,22 @@ export interface paths { patch: operations["updateGroup"]; trace?: never; }; + "/api/documents/{id}/training-labels": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["patchTrainingLabel"]; + trace?: never; + }; "/api/documents/{documentId}/comments/{commentId}": { parameters: { query?: never; @@ -676,6 +708,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/ocr/training-info": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getTrainingInfo"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/ocr/training-data/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["exportTrainingData"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/ocr/jobs/{jobId}": { parameters: { query?: never; @@ -1106,7 +1170,6 @@ export interface components { receivers?: components["schemas"]["Person"][]; sender?: components["schemas"]["Person"]; tags?: components["schemas"]["Tag"][]; - /** @enum {string} */ trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[]; }; UpdateTranscriptionBlockDTO: { @@ -1174,6 +1237,24 @@ export interface components { /** Format: date-time */ createdAt: string; }; + OcrTrainingRun: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + status: "RUNNING" | "DONE" | "FAILED"; + /** Format: int32 */ + blockCount: number; + /** Format: int32 */ + documentCount: number; + modelName: string; + errorMessage?: string; + /** Format: uuid */ + triggeredBy?: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + completedAt?: string; + }; BatchOcrDTO: { documentIds: string[]; }; @@ -1314,6 +1395,10 @@ export interface components { actorName?: string; documentTitle?: string; }; + TrainingLabelRequest: { + label?: string; + enrolled?: boolean; + }; StatsDTO: { /** Format: int64 */ totalPersons?: number; @@ -1325,8 +1410,6 @@ export interface components { /** Format: uuid */ id?: string; displayName?: string; - /** Format: int64 */ - documentCount?: number; firstName?: string; lastName?: string; /** Format: int32 */ @@ -1335,8 +1418,22 @@ export interface components { deathYear?: number; alias?: string; notes?: string; + /** Format: int64 */ + documentCount?: number; personType?: string; }; + TrainingInfoResponse: { + /** Format: int32 */ + availableBlocks?: number; + /** Format: int32 */ + totalOcrBlocks?: number; + /** Format: int32 */ + availableDocuments?: number; + ocrServiceAvailable?: boolean; + lastRun?: components["schemas"]["OcrTrainingRun"]; + runs?: components["schemas"]["OcrTrainingRun"][]; + }; + StreamingResponseBody: unknown; OcrJob: { /** Format: uuid */ id: string; @@ -1381,11 +1478,11 @@ export interface components { empty?: boolean; }; PageableObject: { - paged?: boolean; /** Format: int32 */ pageNumber?: number; /** Format: int32 */ pageSize?: number; + paged?: boolean; /** Format: int64 */ offset?: number; sort?: components["schemas"]["SortObject"]; @@ -2082,6 +2179,26 @@ export interface operations { }; }; }; + triggerTraining: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["OcrTrainingRun"]; + }; + }; + }; + }; triggerBatch: { parameters: { query?: never; @@ -2743,6 +2860,30 @@ export interface operations { }; }; }; + patchTrainingLabel: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TrainingLabelRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; deleteComment: { parameters: { query?: never; @@ -2923,6 +3064,46 @@ export interface operations { }; }; }; + getTrainingInfo: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["TrainingInfoResponse"]; + }; + }; + }; + }; + exportTrainingData: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["StreamingResponseBody"]; + }; + }; + }; + }; getJobStatus: { parameters: { query?: never; diff --git a/frontend/src/routes/admin/system/+page.svelte b/frontend/src/routes/admin/system/+page.svelte index 2bc02c83..acd58204 100644 --- a/frontend/src/routes/admin/system/+page.svelte +++ b/frontend/src/routes/admin/system/+page.svelte @@ -1,6 +1,12 @@