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)
+
+
+
+ {training ? '…' : 'Training starten'}
+
+
+ {#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 @@
+
+
+
+
+
+ Datum
+ Status
+ Blöcke
+ Dokumente
+
+
+
+ {#if runs.length === 0}
+
+
+ Noch keine Trainings-Läufe.
+
+
+ {:else}
+ {#each runs as run (run.id)}
+
+ {formatDate(run.createdAt)}
+
+ {#if run.status === 'DONE'}
+ ✓ Fertig
+ {:else if run.status === 'FAILED'}
+ ✗ Fehler
+ {:else}
+ Läuft…
+ {/if}
+
+ {run.blockCount}
+ {run.documentCount}
+
+ {/each}
+ {/if}
+
+
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 @@