From 33dc4654e53caa97f73338daa0aad17249507a36 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 13 Apr 2026 13:16:25 +0200 Subject: [PATCH 01/40] fix(ocr): use correct Kraken record attributes for line geometry BaselineOCRRecord has 'baseline' and 'boundary' attributes, not 'line' and 'cuts'. The fallback used record.line which doesn't exist, causing AttributeError on every Kurrent OCR page. Co-Authored-By: Claude Opus 4.6 (1M context) --- ocr-service/engines/kraken.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ocr-service/engines/kraken.py b/ocr-service/engines/kraken.py index ce994dd7..5d967764 100644 --- a/ocr-service/engines/kraken.py +++ b/ocr-service/engines/kraken.py @@ -47,7 +47,7 @@ def extract_page_blocks(image, page_idx: int, language: str = "de") -> list[dict pred_it = rpred.rpred(_model, image, baseline_seg) for record in pred_it: - polygon_pts = record.cuts if hasattr(record, "cuts") else [] + polygon_pts = record.boundary if hasattr(record, "boundary") and record.boundary else [] if polygon_pts: xs = [p[0] for p in polygon_pts] @@ -55,8 +55,8 @@ def extract_page_blocks(image, page_idx: int, language: str = "de") -> list[dict x1, y1 = min(xs), min(ys) x2, y2 = max(xs), max(ys) else: - xs = [p[0] for p in record.line] - ys = [p[1] for p in record.line] + xs = [p[0] for p in record.baseline] + ys = [p[1] for p in record.baseline] x1, y1 = min(xs), min(ys) - 5 x2, y2 = max(xs), max(ys) + 5 -- 2.49.1 From 73229077beca2510c06e207a31f6d0804ab3dc44 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 13 Apr 2026 13:59:35 +0200 Subject: [PATCH 02/40] feat(transcription): add sticky review progress counter to TranscriptionEditView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows 'X / Y geprüft' with a brand-mint progress bar at the top of the transcription panel. Derived from the blocks prop — no extra state. Co-Authored-By: Claude Sonnet 4.6 --- .../components/TranscriptionEditView.svelte | 149 ++++++++++-------- .../TranscriptionEditView.svelte.spec.ts | 28 +++- 2 files changed, 109 insertions(+), 68 deletions(-) diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index a8f27ec3..e93471ac 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -50,6 +50,9 @@ let debounceTimers = new SvelteMap>(); let pendingTexts = new SvelteMap(); let sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder)); let hasBlocks = $derived(blocks.length > 0); +let reviewedCount = $derived(blocks.filter((b) => b.reviewed).length); +let totalCount = $derived(blocks.length); +let reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0); function getSaveState(blockId: string): SaveState { return saveStates.get(blockId) ?? 'idle'; @@ -263,78 +266,92 @@ $effect(() => { }); -
+
{#if hasBlocks} - -
- {#each sortedBlocks as block, i (block.id)} - {#if dropTargetIdx === i} -
- {/if} - + +
+

+ {reviewedCount} / {totalCount} geprüft +

+
handleGripDown(e, block.id)} - class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}" - style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''} - > - handleTextChange(block.id, text)} - onFocus={() => handleFocus(block.id)} - onDeleteClick={() => handleDelete(block.id)} - onRetry={() => handleRetry(block.id)} - onReviewToggle={() => onReviewToggle(block.id)} - onMoveUp={() => handleMoveUp(block.id)} - onMoveDown={() => handleMoveDown(block.id)} - isFirst={i === 0} - isLast={i === sortedBlocks.length - 1} - /> -
- {/each} - - {#if dropTargetIdx === sortedBlocks.length} -
- {/if} - - -
- {m.transcription_next_block_cta({ number: sortedBlocks.length + 1 })} + class="h-full rounded-full bg-brand-mint transition-all duration-300" + style="width: {reviewProgress}%" + >
- - {#if canRunOcr && onTriggerOcr} -
- +
+ +
+ {#each sortedBlocks as block, i (block.id)} + {#if dropTargetIdx === i} +
+ {/if} + +
handleGripDown(e, block.id)} + class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}" + style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''} > - {m.ocr_rerun_label()} -
-
- handleTextChange(block.id, text)} + onFocus={() => handleFocus(block.id)} + onDeleteClick={() => handleDelete(block.id)} + onRetry={() => handleRetry(block.id)} + onReviewToggle={() => onReviewToggle(block.id)} + onMoveUp={() => handleMoveUp(block.id)} + onMoveDown={() => handleMoveDown(block.id)} + isFirst={i === 0} + isLast={i === sortedBlocks.length - 1} />
-
- {/if} + {/each} + + {#if dropTargetIdx === sortedBlocks.length} +
+ {/if} + + +
+ {m.transcription_next_block_cta({ number: sortedBlocks.length + 1 })} +
+ + {#if canRunOcr && onTriggerOcr} +
+ + {m.ocr_rerun_label()} + +
+ +
+
+ {/if} +
{:else}
diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts index c7fd211e..a6490eb5 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts @@ -13,7 +13,9 @@ const block1 = { text: 'Block eins', label: null, sortOrder: 0, - version: 0 + version: 0, + source: 'MANUAL' as const, + reviewed: false }; const block2 = { id: 'b2', @@ -22,7 +24,9 @@ const block2 = { text: 'Block zwei', label: null, sortOrder: 1, - version: 0 + version: 0, + source: 'OCR' as const, + reviewed: true }; function renderView(overrides: Record = {}, service = createConfirmService()) { @@ -232,3 +236,23 @@ describe('TranscriptionEditView — delete block', () => { expect(onDeleteBlock).not.toHaveBeenCalled(); }); }); + +// ─── Review progress counter ────────────────────────────────────────────────── + +describe('TranscriptionEditView — review progress counter', () => { + it('shows reviewed count and total when blocks exist', async () => { + // block1: reviewed=false, block2: reviewed=true → "1 / 2 geprüft" + renderView(); + await expect.element(page.getByText(/1 \/ 2 geprüft/)).toBeInTheDocument(); + }); + + it('shows 0 reviewed when no blocks are reviewed', async () => { + renderView({ blocks: [block1] }); // block1.reviewed = false + await expect.element(page.getByText(/0 \/ 1 geprüft/)).toBeInTheDocument(); + }); + + it('does not show progress counter when there are no blocks', async () => { + renderView({ blocks: [] }); + await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument(); + }); +}); -- 2.49.1 From fdf1eb92adf742c7d7fa420e4791848dca38664b Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 13 Apr 2026 14:30:51 +0200 Subject: [PATCH 03/40] feat(training): add document-level training enrollment - V29 migration: document_training_labels join table - TrainingLabel enum: KURRENT_RECOGNITION, KURRENT_SEGMENTATION - Document.trainingLabels @ElementCollection - DocumentService.addTrainingLabel / removeTrainingLabel - PATCH /api/documents/{id}/training-labels (WRITE_ALL) - Auto-enroll on Kurrent OCR trigger (OcrService.startOcr) - TranscriptionEditView: enrollment chips in panel footer - JPQL queries updated to use MEMBER OF trainingLabels Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 26 ++ .../familienarchiv/model/Document.java | 7 + .../familienarchiv/model/TrainingLabel.java | 6 + .../TranscriptionBlockRepository.java | 19 ++ .../service/DocumentService.java | 15 + .../familienarchiv/service/OcrService.java | 3 + .../V29__add_document_training_labels.sql | 5 + .../controller/DocumentControllerTest.java | 55 ++++ .../repository/TrainingBlockQueryTest.java | 124 +++++++ .../components/TranscriptionEditView.svelte | 47 ++- frontend/src/lib/generated/api.ts | 306 +++++++++++++++++- .../src/routes/documents/[id]/+page.svelte | 12 + 12 files changed, 614 insertions(+), 11 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/TrainingLabel.java create mode 100644 backend/src/main/resources/db/migration/V29__add_document_training_labels.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/repository/TrainingBlockQueryTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index 6252a5ad..92d853c7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -21,6 +21,7 @@ import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.dto.DocumentSort; import org.raddatz.familienarchiv.model.DocumentStatus; +import org.raddatz.familienarchiv.model.TrainingLabel; import org.raddatz.familienarchiv.model.DocumentVersion; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; @@ -35,9 +36,11 @@ import org.springframework.core.io.InputStreamResource; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; @@ -208,6 +211,29 @@ public class DocumentController { return ResponseEntity.ok(DocumentSearchResult.of(results)); } + // --- TRAINING LABELS --- + + public record TrainingLabelRequest(String label, boolean enrolled) {} + + @PatchMapping("/{id}/training-labels") + @RequirePermission(Permission.WRITE_ALL) + public ResponseEntity patchTrainingLabel( + @PathVariable UUID id, + @RequestBody TrainingLabelRequest req) { + TrainingLabel label; + try { + label = TrainingLabel.valueOf(req.label()); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown training label: " + req.label()); + } + if (req.enrolled()) { + documentService.addTrainingLabel(id, label); + } else { + documentService.removeTrainingLabel(id, label); + } + return ResponseEntity.noContent().build(); + } + // --- VERSIONS --- @GetMapping("/{id}/versions") diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java index e5be77a3..5a5ca54a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java @@ -110,4 +110,11 @@ public class Document { @JoinTable(name = "document_tags", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "tag_id")) @Builder.Default private Set tags = new HashSet<>(); + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "document_training_labels", joinColumns = @JoinColumn(name = "document_id")) + @Column(name = "label") + @Enumerated(EnumType.STRING) + @Builder.Default + private Set trainingLabels = new HashSet<>(); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/TrainingLabel.java b/backend/src/main/java/org/raddatz/familienarchiv/model/TrainingLabel.java new file mode 100644 index 00000000..d78585aa --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/TrainingLabel.java @@ -0,0 +1,6 @@ +package org.raddatz.familienarchiv.model; + +public enum TrainingLabel { + KURRENT_RECOGNITION, + KURRENT_SEGMENTATION +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java index 2e6c3365..9b924ed5 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.repository; import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.List; import java.util.Optional; @@ -14,4 +15,22 @@ public interface TranscriptionBlockRepository extends JpaRepository findByIdAndDocumentId(UUID id, UUID documentId); int countByDocumentId(UUID documentId); + + @Query(""" + SELECT b FROM TranscriptionBlock b + JOIN DocumentAnnotation a ON a.id = b.annotationId + JOIN Document d ON d.id = b.documentId + WHERE (b.source = 'MANUAL' OR (b.source = 'OCR' AND b.reviewed = true)) + AND 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels + """) + List findEligibleKurrentBlocks(); + + @Query(""" + SELECT b FROM TranscriptionBlock b + JOIN DocumentAnnotation a ON a.id = b.annotationId + JOIN Document d ON d.id = b.documentId + WHERE b.source = 'MANUAL' AND (b.text IS NULL OR b.text = '') + AND 'KURRENT_SEGMENTATION' MEMBER OF d.trainingLabels + """) + List findSegmentationBlocks(); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index f06a9922..db2d6db6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -9,6 +9,7 @@ import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.dto.DocumentSort; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.ScriptType; +import org.raddatz.familienarchiv.model.TrainingLabel; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.repository.DocumentRepository; @@ -385,6 +386,20 @@ public class DocumentService { documentRepository.save(doc); } + @Transactional + public void addTrainingLabel(UUID documentId, TrainingLabel label) { + Document doc = getDocumentById(documentId); + doc.getTrainingLabels().add(label); + documentRepository.save(doc); + } + + @Transactional + public void removeTrainingLabel(UUID documentId, TrainingLabel label) { + Document doc = getDocumentById(documentId); + doc.getTrainingLabels().remove(label); + documentRepository.save(doc); + } + public Document getDocumentById(UUID id) { return documentRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrService.java index dcc14dd1..cacf61af 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrService.java @@ -66,6 +66,9 @@ public class OcrService { if (scriptTypeOverride != null) { documentService.updateScriptType(documentId, scriptTypeOverride); + if (scriptTypeOverride == ScriptType.HANDWRITING_KURRENT) { + documentService.addTrainingLabel(documentId, TrainingLabel.KURRENT_RECOGNITION); + } } OcrJob job = OcrJob.builder() diff --git a/backend/src/main/resources/db/migration/V29__add_document_training_labels.sql b/backend/src/main/resources/db/migration/V29__add_document_training_labels.sql new file mode 100644 index 00000000..36c20f10 --- /dev/null +++ b/backend/src/main/resources/db/migration/V29__add_document_training_labels.sql @@ -0,0 +1,5 @@ +CREATE TABLE document_training_labels ( + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + label VARCHAR(50) NOT NULL, + PRIMARY KEY (document_id, label) +); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index d8f93e4e..58cd78a6 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -14,6 +14,7 @@ import org.raddatz.familienarchiv.service.FileService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.raddatz.familienarchiv.config.SecurityConfig; +import org.springframework.http.MediaType; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; import org.springframework.context.annotation.Import; import org.springframework.security.test.context.support.WithMockUser; @@ -31,8 +32,10 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.eq; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -485,6 +488,58 @@ class DocumentControllerTest { .andExpect(jsonPath("$[0].editorName").value("Emma Müller")); } + // ─── PATCH /api/documents/{id}/training-labels ─────────────────────────── + + @Test + void patchTrainingLabels_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void patchTrainingLabels_returns403_whenMissingWritePermission() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchTrainingLabels_returns204_whenAddingLabel() throws Exception { + UUID id = UUID.randomUUID(); + mockMvc.perform(patch("/api/documents/" + id + "/training-labels") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) + .andExpect(status().isNoContent()); + + verify(documentService).addTrainingLabel(eq(id), any()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchTrainingLabels_returns204_whenRemovingLabel() throws Exception { + UUID id = UUID.randomUUID(); + mockMvc.perform(patch("/api/documents/" + id + "/training-labels") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"label\":\"KURRENT_SEGMENTATION\",\"enrolled\":false}")) + .andExpect(status().isNoContent()); + + verify(documentService).removeTrainingLabel(eq(id), any()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchTrainingLabels_returns400_whenUnknownLabel() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"label\":\"UNKNOWN_GARBAGE\",\"enrolled\":true}")) + .andExpect(status().isBadRequest()); + } + // ─── GET /api/documents/{id}/versions/{versionId} ──────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/TrainingBlockQueryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/TrainingBlockQueryTest.java new file mode 100644 index 00000000..d7406ba0 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/TrainingBlockQueryTest.java @@ -0,0 +1,124 @@ +package org.raddatz.familienarchiv.repository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.raddatz.familienarchiv.model.*; +import java.util.Set; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class TrainingBlockQueryTest { + + @Autowired TranscriptionBlockRepository blockRepository; + @Autowired DocumentRepository documentRepository; + @Autowired AnnotationRepository annotationRepository; + + private UUID kurrentDocId; + private UUID typewriterDocId; + private UUID kurrentAnnotationId; + private UUID typewriterAnnotationId; + + @BeforeEach + void setUp() { + Document kurrentDoc = documentRepository.save(Document.builder() + .title("Kurrent Brief") + .originalFilename("kurrent.pdf") + .status(DocumentStatus.UPLOADED) + .trainingLabels(new java.util.HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION))) + .build()); + kurrentDocId = kurrentDoc.getId(); + + Document typewriterDoc = documentRepository.save(Document.builder() + .title("Getippter Brief") + .originalFilename("typed.pdf") + .status(DocumentStatus.UPLOADED) + .build()); + typewriterDocId = typewriterDoc.getId(); + + kurrentAnnotationId = annotationRepository.save(annotation(kurrentDocId)).getId(); + typewriterAnnotationId = annotationRepository.save(annotation(typewriterDocId)).getId(); + } + + @Test + void findEligibleKurrentBlocks_includesManualBlock() { + blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.MANUAL, false)); + + List result = blockRepository.findEligibleKurrentBlocks(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getSource()).isEqualTo(BlockSource.MANUAL); + } + + @Test + void findEligibleKurrentBlocks_includesReviewedOcrBlock() { + blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.OCR, true)); + + List result = blockRepository.findEligibleKurrentBlocks(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).isReviewed()).isTrue(); + } + + @Test + void findEligibleKurrentBlocks_excludesUnreviewedOcrBlock() { + blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.OCR, false)); + + List result = blockRepository.findEligibleKurrentBlocks(); + + assertThat(result).isEmpty(); + } + + @Test + void findEligibleKurrentBlocks_excludesNonEnrolledDocument() { + blockRepository.save(block(typewriterDocId, typewriterAnnotationId, BlockSource.MANUAL, false)); + + List result = blockRepository.findEligibleKurrentBlocks(); + + assertThat(result).isEmpty(); + } + + @Test + void findEligibleKurrentBlocks_returnsAllEligibleAcrossBothSources() { + blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.MANUAL, false)); + blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.OCR, true)); + blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.OCR, false)); // excluded + + List result = blockRepository.findEligibleKurrentBlocks(); + + assertThat(result).hasSize(2); + } + + // ─── helpers ───────────────────────────────────────────────────────────── + + private DocumentAnnotation annotation(UUID docId) { + return DocumentAnnotation.builder() + .documentId(docId) + .pageNumber(1) + .x(0.1).y(0.2).width(0.3).height(0.4) + .color("#00C7B1") + .build(); + } + + private TranscriptionBlock block(UUID docId, UUID annotId, BlockSource source, boolean reviewed) { + return TranscriptionBlock.builder() + .annotationId(annotId) + .documentId(docId) + .text("Liebe Tante") + .sortOrder(0) + .source(source) + .reviewed(reviewed) + .build(); + } +} diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index e93471ac..f49ea411 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -20,6 +20,9 @@ type Props = { onDeleteBlock: (blockId: string) => Promise; onReviewToggle: (blockId: string) => Promise; onTriggerOcr?: (scriptType: string) => void; + canWrite?: boolean; + trainingLabels?: string[]; + onToggleTrainingLabel?: (label: string, enrolled: boolean) => Promise; }; let { @@ -34,10 +37,14 @@ let { onSaveBlock, onDeleteBlock, onReviewToggle, - onTriggerOcr + onTriggerOcr, + canWrite = false, + trainingLabels = [], + onToggleTrainingLabel }: Props = $props(); let activeBlockId: string | null = $state(null); +let localLabels: string[] = $derived.by(() => [...trainingLabels]); // Sync: when an annotation is clicked on the PDF, activate the corresponding block $effect(() => { @@ -188,7 +195,7 @@ let dropTargetIdx: number | null = $state(null); let dragOffsetY: number = $state(0); let dragStartY = 0; let capturedEl: HTMLElement | null = null; -let listEl: HTMLElement | null = null; +let listEl: HTMLElement | null = $state(null); function handleGripDown(e: PointerEvent, blockId: string) { if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return; @@ -240,6 +247,23 @@ function handlePointerUp() { capturedEl = null; } +async function handleLabelToggle(label: string) { + if (!onToggleTrainingLabel) return; + const enrolled = !localLabels.includes(label); + // Optimistic update + if (enrolled) { + localLabels = [...localLabels, label]; + } else { + localLabels = localLabels.filter((l) => l !== label); + } + try { + await onToggleTrainingLabel(label, enrolled); + } catch { + // Revert on failure + localLabels = [...trainingLabels]; + } +} + function flushViaBeacon() { for (const [blockId, text] of pendingTexts) { clearDebounce(blockId); @@ -390,4 +414,23 @@ $effect(() => { {/if}
{/if} + + {#if canWrite} +
+

Für Training vormerken

+
+ {#each [{ label: 'KURRENT_RECOGNITION', display: 'Kurrent-Erkennung' }, { label: 'KURRENT_SEGMENTATION', display: 'Segmentierung' }] as chip (chip.label)} + + {/each} +
+
+ {/if}
diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index ea330706..16961ab4 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -116,6 +116,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/{documentId}/transcription-blocks/{blockId}/review": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["reviewBlock"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/{documentId}/transcription-blocks/reorder": { parameters: { query?: never; @@ -212,6 +228,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/ocr/batch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["triggerBatch"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/notifications/read-all": { parameters: { query?: never; @@ -308,6 +340,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/{documentId}/ocr": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["triggerOcr"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/{documentId}/comments": { parameters: { query?: never; @@ -628,6 +676,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/ocr/jobs/{jobId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getJobStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/ocr/jobs/{jobId}/progress": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["streamProgress"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/notifications": { parameters: { query?: never; @@ -740,6 +820,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/{documentId}/ocr-status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getDocumentOcrStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/search": { parameters: { query?: never; @@ -940,6 +1036,7 @@ export interface components { name: string; }; PersonUpdateDTO: { + title?: string; firstName?: string; lastName?: string; alias?: string; @@ -978,6 +1075,8 @@ export interface components { receiverIds?: string[]; tags?: string; metadataComplete?: boolean; + /** @enum {string} */ + scriptType?: "UNKNOWN" | "TYPEWRITER" | "HANDWRITING_LATIN" | "HANDWRITING_KURRENT"; }; Document: { /** Format: uuid */ @@ -1002,9 +1101,13 @@ export interface components { /** Format: date-time */ updatedAt: string; metadataComplete: boolean; + /** @enum {string} */ + scriptType: "UNKNOWN" | "TYPEWRITER" | "HANDWRITING_LATIN" | "HANDWRITING_KURRENT"; receivers?: components["schemas"]["Person"][]; sender?: components["schemas"]["Person"]; tags?: components["schemas"]["Tag"][]; + /** @enum {string} */ + trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[]; }; UpdateTranscriptionBlockDTO: { text?: string; @@ -1021,6 +1124,9 @@ export interface components { label?: string; /** Format: int32 */ sortOrder: number; + /** @enum {string} */ + source: "MANUAL" | "OCR"; + reviewed: boolean; /** Format: int32 */ version: number; /** Format: uuid */ @@ -1068,6 +1174,9 @@ export interface components { /** Format: date-time */ createdAt: string; }; + BatchOcrDTO: { + documentIds: string[]; + }; GroupDTO: { name?: string; permissions?: string[]; @@ -1118,6 +1227,10 @@ export interface components { firstName: string; lastName: string; }; + TriggerOcrDTO: { + /** @enum {string} */ + scriptType?: "UNKNOWN" | "TYPEWRITER" | "HANDWRITING_LATIN" | "HANDWRITING_KURRENT"; + }; CreateAnnotationDTO: { /** Format: int32 */ pageNumber?: number; @@ -1130,6 +1243,7 @@ export interface components { /** Format: double */ height?: number; color?: string; + polygon?: number[][]; }; DocumentAnnotation: { /** Format: uuid */ @@ -1148,6 +1262,7 @@ export interface components { height: number; color: string; fileHash?: string; + polygon?: number[][]; /** Format: uuid */ createdBy?: string; /** Format: date-time */ @@ -1210,6 +1325,8 @@ export interface components { /** Format: uuid */ id?: string; displayName?: string; + /** Format: int64 */ + documentCount?: number; firstName?: string; lastName?: string; /** Format: int32 */ @@ -1219,14 +1336,37 @@ export interface components { alias?: string; notes?: string; personType?: string; + }; + OcrJob: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + status: "PENDING" | "RUNNING" | "DONE" | "FAILED"; + /** Format: int32 */ + totalDocuments: number; + /** Format: int32 */ + processedDocuments: number; + /** Format: int32 */ + errorCount: number; + /** Format: int32 */ + skippedCount: number; + progressMessage?: string; + /** Format: uuid */ + createdBy?: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + SseEmitter: { /** Format: int64 */ - documentCount?: number; + timeout?: number; }; PageNotificationDTO: { - /** Format: int64 */ - totalElements?: number; /** Format: int32 */ totalPages?: number; + /** Format: int64 */ + totalElements?: number; pageable?: components["schemas"]["PageableObject"]; /** Format: int32 */ size?: number; @@ -1234,10 +1374,10 @@ export interface components { /** Format: int32 */ number?: number; sort?: components["schemas"]["SortObject"]; - /** Format: int32 */ - numberOfElements?: number; first?: boolean; last?: boolean; + /** Format: int32 */ + numberOfElements?: number; empty?: boolean; }; PageableObject: { @@ -1256,10 +1396,6 @@ export interface components { empty?: boolean; unsorted?: boolean; }; - SseEmitter: { - /** Format: int64 */ - timeout?: number; - }; DocumentVersionSummary: { /** Format: uuid */ id: string; @@ -1292,6 +1428,15 @@ export interface components { /** Format: date-time */ changedAt: string; }; + OcrStatusDTO: { + status?: string; + /** Format: uuid */ + jobId?: string; + /** Format: int32 */ + currentPage?: number; + /** Format: int32 */ + totalPages?: number; + }; DocumentSearchResult: { documents?: components["schemas"]["Document"][]; /** Format: int64 */ @@ -1702,6 +1847,29 @@ export interface operations { }; }; }; + reviewBlock: { + parameters: { + query?: never; + header?: never; + path: { + documentId: string; + blockId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["TranscriptionBlock"]; + }; + }; + }; + }; reorderBlocks: { parameters: { query?: never; @@ -1914,6 +2082,32 @@ export interface operations { }; }; }; + triggerBatch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BatchOcrDTO"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": { + [key: string]: string; + }; + }; + }; + }; + }; markAllRead: { parameters: { query?: never; @@ -2124,6 +2318,34 @@ export interface operations { }; }; }; + triggerOcr: { + parameters: { + query?: never; + header?: never; + path: { + documentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TriggerOcrDTO"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": { + [key: string]: string; + }; + }; + }; + }; + }; getDocumentComments: { parameters: { query?: never; @@ -2701,6 +2923,50 @@ export interface operations { }; }; }; + getJobStatus: { + parameters: { + query?: never; + header?: never; + path: { + jobId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["OcrJob"]; + }; + }; + }; + }; + streamProgress: { + parameters: { + query?: never; + header?: never; + path: { + jobId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": components["schemas"]["SseEmitter"]; + }; + }; + }; + }; getNotifications: { parameters: { query?: { @@ -2860,6 +3126,28 @@ export interface operations { }; }; }; + getDocumentOcrStatus: { + parameters: { + query?: never; + header?: never; + path: { + documentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["OcrStatusDTO"]; + }; + }; + }; + }; search_1: { parameters: { query?: { diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 11b4f185..702a465a 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -129,6 +129,15 @@ async function reviewToggle(blockId: string) { transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b)); } +async function toggleTrainingLabel(label: string, enrolled: boolean) { + const res = await fetch(`/api/documents/${doc.id}/training-labels`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label, enrolled }) + }); + if (!res.ok) throw new Error('Failed to update training label'); +} + let ocrRunning = $state(false); let ocrProgressMessage = $state(''); let ocrErrorMessage = $state(''); @@ -449,11 +458,14 @@ onMount(() => { activeAnnotationId={activeAnnotationId} storedScriptType={doc.scriptType ?? ''} canRunOcr={canWrite && !!doc.filePath} + canWrite={canWrite} + trainingLabels={doc.trainingLabels ?? []} onBlockFocus={handleBlockFocus} onSaveBlock={saveBlock} onDeleteBlock={deleteBlock} onReviewToggle={reviewToggle} onTriggerOcr={triggerOcr} + onToggleTrainingLabel={toggleTrainingLabel} /> {/if}
-- 2.49.1 From cfa3c4df67f50be8fa2800f32c3aff0b8404ca01 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 13 Apr 2026 14:35:06 +0200 Subject: [PATCH 04/40] feat(training): add recognition training data export - TrainingDataExportService: PDFBox rendering at 300 DPI, crop by annotation coordinates, ZIP with .png + .gt.txt pairs - Skips documents with missing S3 files (logs WARN, continues) - GET /api/ocr/training-data/export (ADMIN); 204 when no enrolled blocks Co-Authored-By: Claude Sonnet 4.6 --- .../controller/OcrController.java | 18 ++ .../service/TrainingDataExportService.java | 141 ++++++++++ .../controller/OcrControllerTest.java | 42 +++ .../TrainingDataExportServiceTest.java | 258 ++++++++++++++++++ 4 files changed, 459 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/TrainingDataExportService.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/TrainingDataExportServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java index 4b8f9cd3..4c0d1d4a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java @@ -12,11 +12,15 @@ import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.service.OcrBatchService; import org.raddatz.familienarchiv.service.OcrProgressService; import org.raddatz.familienarchiv.service.OcrService; +import org.raddatz.familienarchiv.service.TrainingDataExportService; import org.raddatz.familienarchiv.service.UserService; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import jakarta.validation.Valid; @@ -32,6 +36,7 @@ public class OcrController { private final OcrBatchService ocrBatchService; private final OcrProgressService ocrProgressService; private final UserService userService; + private final TrainingDataExportService trainingDataExportService; @PostMapping("/api/documents/{documentId}/ocr") @ResponseStatus(HttpStatus.ACCEPTED) @@ -75,6 +80,19 @@ public class OcrController { return ocrService.getDocumentOcrStatus(documentId); } + @GetMapping("/api/ocr/training-data/export") + @RequirePermission(Permission.ADMIN) + public ResponseEntity exportTrainingData() { + if (trainingDataExportService.queryEligibleBlocks().isEmpty()) { + return ResponseEntity.noContent().build(); + } + StreamingResponseBody body = trainingDataExportService.exportToZip(); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/zip")) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"training-data.zip\"") + .body(body); + } + private UUID resolveUserId(Authentication authentication) { if (authentication == null || !authentication.isAuthenticated()) return null; try { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TrainingDataExportService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TrainingDataExportService.java new file mode 100644 index 00000000..06a23946 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TrainingDataExportService.java @@ -0,0 +1,141 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentAnnotation; +import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.raddatz.familienarchiv.repository.AnnotationRepository; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TrainingDataExportService { + + private final TranscriptionBlockRepository blockRepository; + private final AnnotationRepository annotationRepository; + private final DocumentRepository documentRepository; + private final FileService fileService; + + public List queryEligibleBlocks() { + return blockRepository.findEligibleKurrentBlocks(); + } + + public StreamingResponseBody exportToZip() { + // Collect all data before entering the lambda — no open DB txn during streaming + List blocks = queryEligibleBlocks(); + if (blocks.isEmpty()) { + return out -> {}; // caller checks isEmpty() for 204 response + } + + // Group blocks by documentId so we only download each PDF once + Map> byDoc = new LinkedHashMap<>(); + for (TranscriptionBlock b : blocks) { + byDoc.computeIfAbsent(b.getDocumentId(), k -> new ArrayList<>()).add(b); + } + + // Pre-fetch annotations keyed by id + Map annotations = new HashMap<>(); + for (TranscriptionBlock b : blocks) { + annotationRepository.findById(b.getAnnotationId()) + .ifPresent(a -> annotations.put(a.getId(), a)); + } + + // Pre-fetch documents keyed by id + Map documents = new HashMap<>(); + for (UUID docId : byDoc.keySet()) { + documentRepository.findById(docId).ifPresent(d -> documents.put(d.getId(), d)); + } + + return out -> { + try (ZipOutputStream zip = new ZipOutputStream(out)) { + for (Map.Entry> entry : byDoc.entrySet()) { + UUID docId = entry.getKey(); + Document doc = documents.get(docId); + if (doc == null || doc.getFilePath() == null) { + log.warn("Skipping document {} — no file path", docId); + continue; + } + + byte[] pdfBytes; + try { + pdfBytes = fileService.downloadFileBytes(doc.getFilePath()); + } catch (FileService.StorageFileNotFoundException | IOException e) { + log.warn("Skipping document {} — S3 download failed: {}", docId, e.getMessage()); + continue; + } + + try (PDDocument pdf = Loader.loadPDF(pdfBytes)) { + PDFRenderer renderer = new PDFRenderer(pdf); + for (TranscriptionBlock block : entry.getValue()) { + DocumentAnnotation ann = annotations.get(block.getAnnotationId()); + if (ann == null) continue; + + int pageIdx = ann.getPageNumber() - 1; // pageNumber is 1-based + if (pageIdx < 0 || pageIdx >= pdf.getNumberOfPages()) continue; + + BufferedImage pageImage = renderPageImage(renderer, pageIdx); + BufferedImage cropped = cropBlockImage(pageImage, ann); + + writeTrainingPair(zip, block.getId(), cropped, block.getText()); + } + } catch (Exception e) { + log.warn("Skipping document {} — rendering failed: {}", docId, e.getMessage()); + } + } + } + }; + } + + BufferedImage renderPageImage(PDFRenderer renderer, int pageIdx) throws IOException { + return renderer.renderImageWithDPI(pageIdx, 300); + } + + BufferedImage cropBlockImage(BufferedImage page, DocumentAnnotation ann) { + int imgW = page.getWidth(); + int imgH = page.getHeight(); + + int x = (int) (ann.getX() * imgW); + int y = (int) (ann.getY() * imgH); + int w = (int) (ann.getWidth() * imgW); + int h = (int) (ann.getHeight() * imgH); + + // Clamp to image bounds + x = Math.max(0, Math.min(x, imgW - 1)); + y = Math.max(0, Math.min(y, imgH - 1)); + w = Math.max(1, Math.min(w, imgW - x)); + h = Math.max(1, Math.min(h, imgH - y)); + + return page.getSubimage(x, y, w, h); + } + + void writeTrainingPair(ZipOutputStream zip, UUID blockId, BufferedImage image, String text) throws IOException { + String base = blockId.toString(); + + // Write PNG + zip.putNextEntry(new ZipEntry(base + ".png")); + ImageIO.write(image, "PNG", zip); + zip.closeEntry(); + + // Write ground-truth text + zip.putNextEntry(new ZipEntry(base + ".gt.txt")); + zip.write((text != null ? text : "").getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java index a7d6d5cf..bdb3a346 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java @@ -43,6 +43,7 @@ class OcrControllerTest { @MockitoBean OcrProgressService ocrProgressService; @MockitoBean UserService userService; @MockitoBean CustomUserDetailsService customUserDetailsService; + @MockitoBean TrainingDataExportService trainingDataExportService; @Test @WithMockUser(authorities = "WRITE_ALL") @@ -121,6 +122,47 @@ class OcrControllerTest { .andExpect(jsonPath("$.jobId").value(jobId.toString())); } + // ─── GET /api/ocr/training-data/export ─────────────────────────────────── + + @Test + void exportTrainingData_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/ocr/training-data/export")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void exportTrainingData_returns403_whenNotAdmin() throws Exception { + mockMvc.perform(get("/api/ocr/training-data/export")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void exportTrainingData_returns204_whenNoEligibleBlocks() throws Exception { + when(trainingDataExportService.queryEligibleBlocks()).thenReturn(List.of()); + + mockMvc.perform(get("/api/ocr/training-data/export")) + .andExpect(status().isNoContent()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void exportTrainingData_returns200_withZipContentType_whenBlocksExist() throws Exception { + org.raddatz.familienarchiv.model.TranscriptionBlock block = + org.raddatz.familienarchiv.model.TranscriptionBlock.builder() + .id(UUID.randomUUID()).documentId(UUID.randomUUID()) + .annotationId(UUID.randomUUID()).text("x").sortOrder(0).build(); + when(trainingDataExportService.queryEligibleBlocks()).thenReturn(List.of(block)); + when(trainingDataExportService.exportToZip()).thenReturn(out -> {}); + + mockMvc.perform(get("/api/ocr/training-data/export")) + .andExpect(status().isOk()) + .andExpect(result -> + org.assertj.core.api.Assertions.assertThat( + result.getResponse().getContentType()).contains("application/zip")); + } + @Test @WithMockUser(authorities = "READ_ALL") void getDocumentOcrStatus_returnsNone_whenNoOcrJobExists() throws Exception { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TrainingDataExportServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TrainingDataExportServiceTest.java new file mode 100644 index 00000000..f214f989 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TrainingDataExportServiceTest.java @@ -0,0 +1,258 @@ +package org.raddatz.familienarchiv.service; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.raddatz.familienarchiv.model.*; +import org.raddatz.familienarchiv.repository.AnnotationRepository; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class TrainingDataExportServiceTest { + + @Autowired TranscriptionBlockRepository blockRepository; + @Autowired DocumentRepository documentRepository; + @Autowired AnnotationRepository annotationRepository; + + static byte[] minimalPdfBytes; + + @BeforeAll + static void createMinimalPdf() throws Exception { + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage(PDRectangle.A4)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + doc.save(out); + minimalPdfBytes = out.toByteArray(); + } + } + + // ─── Query: enrollment filter ───────────────────────────────────────────── + + @Test + void export_includesManualBlockFromEnrolledDocument() throws Exception { + UUID docId = enrolledDoc("enrolled.pdf"); + UUID annotId = annotation(docId); + blockRepository.save(manualBlock(docId, annotId, "Liebe Mutter")); + + FileService fileService = mockFileService(); + TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); + + StreamingResponseBody body = service.exportToZip(); + byte[] zipBytes = stream(body); + assertThat(zipEntryNames(zipBytes)).isNotEmpty(); + } + + @Test + void export_excludesManualBlockFromNonEnrolledDocument() throws Exception { + UUID docId = nonEnrolledDoc("notenrolled.pdf"); + UUID annotId = annotation(docId); + blockRepository.save(manualBlock(docId, annotId, "Liebe Tante")); + + FileService fileService = mockFileService(); + TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); + + StreamingResponseBody body = service.exportToZip(); + byte[] zipBytes = stream(body); + assertThat(zipEntryNames(zipBytes)).isEmpty(); + } + + @Test + void export_includesReviewedOcrBlockFromEnrolledDocument() throws Exception { + UUID docId = enrolledDoc("ocr-reviewed.pdf"); + UUID annotId = annotation(docId); + TranscriptionBlock block = TranscriptionBlock.builder() + .annotationId(annotId).documentId(docId) + .text("OCR text").sortOrder(0) + .source(BlockSource.OCR).reviewed(true).build(); + blockRepository.save(block); + + FileService fileService = mockFileService(); + TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); + + StreamingResponseBody body = service.exportToZip(); + assertThat(zipEntryNames(stream(body))).isNotEmpty(); + } + + @Test + void export_excludesUnreviewedOcrBlockFromEnrolledDocument() throws Exception { + UUID docId = enrolledDoc("ocr-unreviewed.pdf"); + UUID annotId = annotation(docId); + TranscriptionBlock block = TranscriptionBlock.builder() + .annotationId(annotId).documentId(docId) + .text("Raw OCR").sortOrder(0) + .source(BlockSource.OCR).reviewed(false).build(); + blockRepository.save(block); + + FileService fileService = mockFileService(); + TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); + + StreamingResponseBody body = service.exportToZip(); + assertThat(zipEntryNames(stream(body))).isEmpty(); + } + + // ─── ZIP structure ──────────────────────────────────────────────────────── + + @Test + void export_producesExactly2EntriesPerBlock_pngAndTxt() throws Exception { + UUID docId = enrolledDoc("zip-struct.pdf"); + UUID annotId = annotation(docId); + blockRepository.save(manualBlock(docId, annotId, "Erste Zeile")); + blockRepository.save(manualBlock(docId, annotId, "Zweite Zeile")); + + FileService fileService = mockFileService(); + TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); + + byte[] zipBytes = stream(service.exportToZip()); + var names = zipEntryNames(zipBytes); + assertThat(names).hasSize(4); // 2 blocks × 2 entries each + assertThat(names.stream().filter(n -> n.endsWith(".png")).count()).isEqualTo(2); + assertThat(names.stream().filter(n -> n.endsWith(".gt.txt")).count()).isEqualTo(2); + } + + @Test + void export_gtTxtContainsBlockText() throws Exception { + UUID docId = enrolledDoc("txt-content.pdf"); + UUID annotId = annotation(docId); + String expectedText = "Sehr geehrte Frau"; + blockRepository.save(manualBlock(docId, annotId, expectedText)); + + FileService fileService = mockFileService(); + TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); + + byte[] zipBytes = stream(service.exportToZip()); + String txtContent = readZipEntry(zipBytes, ".gt.txt"); + assertThat(txtContent).isEqualTo(expectedText); + } + + // ─── S3 failure resilience ──────────────────────────────────────────────── + + @Test + void export_skipsDocumentWhenS3DownloadFails_andStillIncludesOtherDocuments() throws Exception { + UUID failDocId = enrolledDoc("fail.pdf"); + UUID okDocId = enrolledDoc("ok.pdf"); + UUID failAnnotId = annotation(failDocId); + UUID okAnnotId = annotation(okDocId); + blockRepository.save(manualBlock(failDocId, failAnnotId, "Will fail")); + blockRepository.save(manualBlock(okDocId, okAnnotId, "Will succeed")); + + FileService fileService = mock(FileService.class); + when(fileService.downloadFileBytes("fail.pdf")).thenThrow(new FileService.StorageFileNotFoundException("missing")); + when(fileService.downloadFileBytes("ok.pdf")).thenReturn(minimalPdfBytes); + + TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); + + byte[] zipBytes = stream(service.exportToZip()); + var names = zipEntryNames(zipBytes); + // ok.pdf block produces 2 entries; fail.pdf block is skipped + assertThat(names).hasSize(2); + } + + // ─── Empty export ───────────────────────────────────────────────────────── + + @Test + void queryEligibleBlocks_returnsEmpty_whenNoEnrolledDocuments() { + FileService fileService = mockFileService(); + TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); + + assertThat(service.queryEligibleBlocks()).isEmpty(); + } + + // ─── helpers ───────────────────────────────────────────────────────────── + + private UUID enrolledDoc(String filename) { + Document doc = documentRepository.save(Document.builder() + .title(filename).originalFilename(filename).filePath(filename) + .status(DocumentStatus.UPLOADED) + .trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION))) + .build()); + return doc.getId(); + } + + private UUID nonEnrolledDoc(String filename) { + Document doc = documentRepository.save(Document.builder() + .title(filename).originalFilename(filename).filePath(filename) + .status(DocumentStatus.UPLOADED) + .build()); + return doc.getId(); + } + + private UUID annotation(UUID docId) { + return annotationRepository.save(DocumentAnnotation.builder() + .documentId(docId).pageNumber(1) + .x(0.1).y(0.1).width(0.8).height(0.1).color("#00C7B1") + .build()).getId(); + } + + private TranscriptionBlock manualBlock(UUID docId, UUID annotId, String text) { + return TranscriptionBlock.builder() + .annotationId(annotId).documentId(docId) + .text(text).sortOrder(0) + .source(BlockSource.MANUAL).reviewed(false).build(); + } + + private FileService mockFileService() { + FileService fs = mock(FileService.class); + try { + when(fs.downloadFileBytes(anyString())).thenReturn(minimalPdfBytes); + } catch (Exception e) { + throw new RuntimeException(e); + } + return fs; + } + + private static byte[] stream(StreamingResponseBody body) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + body.writeTo(out); + return out.toByteArray(); + } + + private static java.util.List zipEntryNames(byte[] zipBytes) throws Exception { + var names = new java.util.ArrayList(); + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + names.add(entry.getName()); + zis.closeEntry(); + } + } + return names; + } + + private static String readZipEntry(byte[] zipBytes, String suffix) throws Exception { + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.getName().endsWith(suffix)) { + return new String(zis.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } + zis.closeEntry(); + } + } + return null; + } +} -- 2.49.1 From bc97a2dade088832a1e92835e945ce794a92fb57 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 13 Apr 2026 14:40:53 +0200 Subject: [PATCH 05/40] feat(ocr): add /train endpoint to OCR service and OcrClient.trainModel() - POST /train in ocr-service with ZIP Slip validation, TemporaryDirectory, ketos transfer learning, timestamped backups (keep last 3), in-process reload - X-Training-Token auth (no-op in dev when TRAINING_TOKEN env is empty) - trainModel() in OcrClient interface + RestClientOcrClient (10-min timeout, multipart upload, forwards X-Training-Token when configured) - TRAINING_TOKEN env var wired in docker-compose; --workers 2 in Dockerfile so /health stays responsive during synchronous training Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/service/OcrClient.java | 10 ++ .../service/RestClientOcrClient.java | 54 ++++++++- .../service/OcrClientDefaultStreamTest.java | 18 ++- docker-compose.yml | 1 + ocr-service/Dockerfile | 2 +- ocr-service/main.py | 111 +++++++++++++++++- 6 files changed, 188 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrClient.java b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrClient.java index 9cf7c886..92330947 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrClient.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrClient.java @@ -10,6 +10,16 @@ import java.util.function.Consumer; public interface OcrClient { List extractBlocks(String pdfUrl, ScriptType scriptType); + /** + * Send a training ZIP to the OCR service for fine-tuning the Kurrent model. + * + * @param trainingDataZip raw ZIP bytes produced by TrainingDataExportService + * @return training result metrics (loss, accuracy, epochs) + */ + TrainingResult trainModel(byte[] trainingDataZip); + + record TrainingResult(Double loss, Double accuracy, Integer epochs) {} + /** * Stream OCR results page-by-page via NDJSON. Implementations should override * this method. The default exists only for backward compatibility during migration diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/RestClientOcrClient.java b/backend/src/main/java/org/raddatz/familienarchiv/service/RestClientOcrClient.java index a0f7ccf3..f2c68187 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/RestClientOcrClient.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/RestClientOcrClient.java @@ -9,9 +9,14 @@ import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.model.ScriptType; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestClient; import java.io.BufferedReader; @@ -36,11 +41,16 @@ public class RestClientOcrClient implements OcrClient, OcrHealthClient { .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true); private final RestClient restClient; + private final RestClient trainingRestClient; private final HttpClient streamingHttpClient; private final String baseUrl; + private final String trainingToken; - public RestClientOcrClient(@Value("${app.ocr.base-url:http://ocr-service:8000}") String baseUrl) { + public RestClientOcrClient( + @Value("${app.ocr.base-url:http://ocr-service:8000}") String baseUrl, + @Value("${app.ocr.training-token:}") String trainingToken) { this.baseUrl = baseUrl; + this.trainingToken = trainingToken; HttpClient httpClient = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_1_1) @@ -54,6 +64,17 @@ public class RestClientOcrClient implements OcrClient, OcrHealthClient { .requestFactory(requestFactory) .build(); + HttpClient trainingHttpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + JdkClientHttpRequestFactory trainingRequestFactory = new JdkClientHttpRequestFactory(trainingHttpClient); + trainingRequestFactory.setReadTimeout(Duration.ofMinutes(10)); + this.trainingRestClient = RestClient.builder() + .baseUrl(baseUrl) + .requestFactory(trainingRequestFactory) + .build(); + this.streamingHttpClient = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_1_1) .connectTimeout(Duration.ofSeconds(10)) @@ -81,6 +102,35 @@ public class RestClientOcrClient implements OcrClient, OcrHealthClient { .toList(); } + @Override + public OcrClient.TrainingResult trainModel(byte[] trainingDataZip) { + ByteArrayResource zipResource = new ByteArrayResource(trainingDataZip) { + @Override + public String getFilename() { return "training-data.zip"; } + }; + + MultiValueMap body = new LinkedMultiValueMap<>(); + HttpHeaders partHeaders = new HttpHeaders(); + partHeaders.setContentType(MediaType.parseMediaType("application/zip")); + body.add("file", new HttpEntity<>(zipResource, partHeaders)); + + var spec = trainingRestClient.post() + .uri("/train") + .contentType(MediaType.MULTIPART_FORM_DATA); + + if (trainingToken != null && !trainingToken.isBlank()) { + spec = spec.header("X-Training-Token", trainingToken); + } + + TrainingResultJson result = spec + .body(body) + .retrieve() + .body(TrainingResultJson.class); + + if (result == null) return new OcrClient.TrainingResult(null, null, null); + return new OcrClient.TrainingResult(result.loss(), result.accuracy(), result.epochs()); + } + @Override public boolean isHealthy() { try { @@ -171,6 +221,8 @@ public class RestClientOcrClient implements OcrClient, OcrHealthClient { } } + record TrainingResultJson(Double loss, Double accuracy, Integer epochs) {} + record OcrBlockJson( @JsonProperty("pageNumber") int pageNumber, double x, diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/OcrClientDefaultStreamTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrClientDefaultStreamTest.java index 42219299..25d129b3 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/OcrClientDefaultStreamTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrClientDefaultStreamTest.java @@ -12,10 +12,15 @@ class OcrClientDefaultStreamTest { @Test void defaultStreamBlocksSynthesizesEventsFromExtractBlocks() { - OcrClient client = (pdfUrl, scriptType) -> List.of( - new OcrBlockResult(0, 0.1, 0.1, 0.8, 0.04, null, "Line 1"), - new OcrBlockResult(0, 0.1, 0.2, 0.8, 0.04, null, "Line 2"), - new OcrBlockResult(1, 0.1, 0.1, 0.8, 0.04, null, "Line 3")); + OcrClient client = new OcrClient() { + @Override public List extractBlocks(String pdfUrl, ScriptType scriptType) { + return List.of( + new OcrBlockResult(0, 0.1, 0.1, 0.8, 0.04, null, "Line 1"), + new OcrBlockResult(0, 0.1, 0.2, 0.8, 0.04, null, "Line 2"), + new OcrBlockResult(1, 0.1, 0.1, 0.8, 0.04, null, "Line 3")); + } + @Override public TrainingResult trainModel(byte[] zip) { return null; } + }; List events = new ArrayList<>(); client.streamBlocks("http://test", ScriptType.TYPEWRITER, events::add); @@ -42,7 +47,10 @@ class OcrClientDefaultStreamTest { @Test void defaultStreamBlocksHandlesEmptyResults() { - OcrClient client = (pdfUrl, scriptType) -> List.of(); + OcrClient client = new OcrClient() { + @Override public List extractBlocks(String pdfUrl, ScriptType scriptType) { return List.of(); } + @Override public TrainingResult trainModel(byte[] zip) { return null; } + }; List events = new ArrayList<>(); client.streamBlocks("http://test", ScriptType.TYPEWRITER, events::add); diff --git a/docker-compose.yml b/docker-compose.yml index 46ed94b2..5e06094b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,6 +87,7 @@ services: - ocr_cache:/root/.cache environment: KRAKEN_MODEL_PATH: /app/models/german_kurrent.mlmodel + TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}" OCR_CONFIDENCE_THRESHOLD: "0.3" OCR_CONFIDENCE_THRESHOLD_KURRENT: "0.5" RECOGNITION_BATCH_SIZE: "16" diff --git a/ocr-service/Dockerfile b/ocr-service/Dockerfile index 0c90503c..7e5cc939 100644 --- a/ocr-service/Dockerfile +++ b/ocr-service/Dockerfile @@ -23,4 +23,4 @@ COPY . . EXPOSE 8000 -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] diff --git a/ocr-service/main.py b/ocr-service/main.py index 11f8b520..9f92ca97 100644 --- a/ocr-service/main.py +++ b/ocr-service/main.py @@ -1,16 +1,21 @@ """OCR microservice — FastAPI app with Surya and Kraken engine support.""" import asyncio +import glob import io import json import logging import os +import shutil +import tempfile +import zipfile from contextlib import asynccontextmanager +from datetime import datetime, timezone from urllib.parse import urlparse import httpx import pypdfium2 as pdfium -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, Header, HTTPException, UploadFile from fastapi.responses import StreamingResponse from PIL import Image @@ -19,6 +24,9 @@ from engines import kraken as kraken_engine from engines import surya as surya_engine from models import OcrBlock, OcrRequest +TRAINING_TOKEN = os.environ.get("TRAINING_TOKEN", "") +KRAKEN_MODEL_PATH = os.environ.get("KRAKEN_MODEL_PATH", "/app/models/german_kurrent.mlmodel") + logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -169,6 +177,107 @@ async def run_ocr_stream(request: OcrRequest): ) +def _check_training_token(x_training_token: str | None) -> None: + """Validate training token if TRAINING_TOKEN env var is set.""" + if TRAINING_TOKEN and x_training_token != TRAINING_TOKEN: + raise HTTPException(status_code=403, detail="Invalid or missing X-Training-Token") + + +def _validate_zip_entry(name: str, extract_dir: str) -> None: + """Reject ZIP Slip attacks: path traversal and absolute paths.""" + if os.path.isabs(name) or name.startswith(".."): + raise HTTPException(status_code=400, detail=f"Unsafe ZIP entry: {name}") + resolved = os.path.realpath(os.path.join(extract_dir, name)) + if not resolved.startswith(os.path.realpath(extract_dir)): + raise HTTPException(status_code=400, detail=f"ZIP Slip detected: {name}") + + +def _rotate_backups(model_path: str, keep: int = 3) -> None: + """Keep only the last `keep` timestamped backups of the model.""" + pattern = model_path + ".*.bak" + backups = sorted(glob.glob(pattern)) + for old in backups[:-keep]: + try: + os.remove(old) + except OSError: + logger.warning("Could not remove old backup: %s", old) + + +@app.post("/train") +async def train_model( + file: UploadFile, + x_training_token: str | None = Header(default=None), +): + """Fine-tune the Kurrent recognition model with uploaded training data. + + Accepts a ZIP archive containing .png/.gt.txt training pairs exported + by the Java backend. Training mutates in-process model state — not safe + if the service is replicated. + """ + _check_training_token(x_training_token) + + if not _models_ready: + raise HTTPException(status_code=503, detail="Models not loaded yet") + + zip_bytes = await file.read() + + training_run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + log = logging.LoggerAdapter(logger, {"training_run_id": training_run_id}) + log.info("Starting training run %s", training_run_id) + + def _run_training() -> dict: + with tempfile.TemporaryDirectory() as tmp_dir: + # Extract ZIP with safety checks + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + for entry in zf.namelist(): + _validate_zip_entry(entry, tmp_dir) + zf.extractall(tmp_dir) + + log.info("Extracted %d ZIP entries to %s", len(os.listdir(tmp_dir)), tmp_dir) + + # Run ketos train (transfer learning from existing model) + from kraken import ketos + ground_truth = glob.glob(os.path.join(tmp_dir, "*.gt.txt")) + if not ground_truth: + raise HTTPException(status_code=422, detail="No ground-truth files found in ZIP") + + log.info("Training on %d ground-truth pairs", len(ground_truth)) + output_model_path = os.path.join(tmp_dir, "fine_tuned.mlmodel") + + result = ketos.train( + ground_truth=ground_truth, + load=KRAKEN_MODEL_PATH, + output=output_model_path, + format_type="path", + ) + + epochs = getattr(result, "epochs", None) or 0 + loss = getattr(result, "best_loss", None) + accuracy = getattr(result, "best_accuracy", None) + + log.info("Training complete — epochs=%s loss=%s accuracy=%s", epochs, loss, accuracy) + + # Backup existing model and replace + if os.path.exists(KRAKEN_MODEL_PATH): + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + backup_path = f"{KRAKEN_MODEL_PATH}.{timestamp}.bak" + shutil.copy2(KRAKEN_MODEL_PATH, backup_path) + log.info("Backed up model to %s", backup_path) + _rotate_backups(KRAKEN_MODEL_PATH, keep=3) + + shutil.move(output_model_path, KRAKEN_MODEL_PATH) + log.info("Replaced model at %s", KRAKEN_MODEL_PATH) + + # Reload model in-process + kraken_engine.load_models() + log.info("Reloaded Kraken model in-process") + + return {"loss": loss, "accuracy": accuracy, "epochs": epochs} + + result = await asyncio.to_thread(_run_training) + return result + + async def _download_and_convert_pdf(url: str) -> list[Image.Image]: """Download a PDF from a presigned URL and convert each page to a PIL Image.""" _validate_url(url) -- 2.49.1 From 88e005eb491dc002b264ab9fa4c8b3eb1efe4b97 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 13 Apr 2026 14:47:56 +0200 Subject: [PATCH 06/40] feat(ocr): add training history + POST /train + GET /training-info endpoints - OcrTrainingRun entity + V30 migration (partial unique index prevents concurrent runs at DB level) - OcrTrainingService: concurrent-run guard, 5-block threshold, MDC log correlation, orphan recovery on ApplicationReadyEvent - POST /api/ocr/train (ADMIN) + GET /api/ocr/training-info (ADMIN) - TRAINING_ALREADY_RUNNING ErrorCode - 6 OcrTrainingServiceTest + 6 OcrControllerTest tests for the new endpoints Co-Authored-By: Claude Sonnet 4.6 --- .../controller/OcrController.java | 17 ++ .../familienarchiv/exception/ErrorCode.java | 2 + .../familienarchiv/model/OcrTrainingRun.java | 57 ++++++ .../familienarchiv/model/TrainingStatus.java | 7 + .../repository/OcrTrainingRunRepository.java | 16 ++ .../service/OcrTrainingService.java | 147 +++++++++++++++ .../migration/V30__add_ocr_training_runs.sql | 16 ++ .../controller/OcrControllerTest.java | 63 +++++++ .../service/OcrTrainingServiceTest.java | 168 ++++++++++++++++++ 9 files changed, 493 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/OcrTrainingRun.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/TrainingStatus.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/repository/OcrTrainingRunRepository.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/OcrTrainingService.java create mode 100644 backend/src/main/resources/db/migration/V30__add_ocr_training_runs.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/OcrTrainingServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java index 4c0d1d4a..23636180 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java @@ -7,11 +7,13 @@ import org.raddatz.familienarchiv.dto.OcrStatusDTO; import org.raddatz.familienarchiv.dto.TriggerOcrDTO; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.OcrJob; +import org.raddatz.familienarchiv.model.OcrTrainingRun; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.service.OcrBatchService; import org.raddatz.familienarchiv.service.OcrProgressService; import org.raddatz.familienarchiv.service.OcrService; +import org.raddatz.familienarchiv.service.OcrTrainingService; import org.raddatz.familienarchiv.service.TrainingDataExportService; import org.raddatz.familienarchiv.service.UserService; import org.springframework.http.HttpHeaders; @@ -37,6 +39,7 @@ public class OcrController { private final OcrProgressService ocrProgressService; private final UserService userService; private final TrainingDataExportService trainingDataExportService; + private final OcrTrainingService ocrTrainingService; @PostMapping("/api/documents/{documentId}/ocr") @ResponseStatus(HttpStatus.ACCEPTED) @@ -93,6 +96,20 @@ public class OcrController { .body(body); } + @PostMapping("/api/ocr/train") + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission(Permission.ADMIN) + public OcrTrainingRun triggerTraining(Authentication authentication) { + UUID userId = resolveUserId(authentication); + return ocrTrainingService.triggerTraining(userId); + } + + @GetMapping("/api/ocr/training-info") + @RequirePermission(Permission.ADMIN) + public OcrTrainingService.TrainingInfoResponse getTrainingInfo() { + return ocrTrainingService.getTrainingInfo(); + } + private UUID resolveUserId(Authentication authentication) { if (authentication == null || !authentication.isAuthenticated()) return null; try { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index e3b0c99c..d8a03b83 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -75,6 +75,8 @@ public enum ErrorCode { OCR_DOCUMENT_NOT_UPLOADED, /** OCR processing failed for the document. 500 */ OCR_PROCESSING_FAILED, + /** A training run is already in progress. 409 */ + TRAINING_ALREADY_RUNNING, // --- Generic --- /** Request validation failed (missing or malformed fields). 400 */ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/OcrTrainingRun.java b/backend/src/main/java/org/raddatz/familienarchiv/model/OcrTrainingRun.java new file mode 100644 index 00000000..add2a2a7 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/OcrTrainingRun.java @@ -0,0 +1,57 @@ +package org.raddatz.familienarchiv.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "ocr_training_runs") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class OcrTrainingRun { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private TrainingStatus status; + + @Column(name = "block_count", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int blockCount; + + @Column(name = "document_count", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int documentCount; + + @Column(name = "model_name", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String modelName; + + @Column(name = "error_message") + private String errorMessage; + + @Column(name = "triggered_by") + private UUID triggeredBy; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Instant createdAt; + + @Column(name = "completed_at") + private Instant completedAt; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/TrainingStatus.java b/backend/src/main/java/org/raddatz/familienarchiv/model/TrainingStatus.java new file mode 100644 index 00000000..7e99dd2f --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/TrainingStatus.java @@ -0,0 +1,7 @@ +package org.raddatz.familienarchiv.model; + +public enum TrainingStatus { + RUNNING, + DONE, + FAILED +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrTrainingRunRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrTrainingRunRepository.java new file mode 100644 index 00000000..0bab0e99 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrTrainingRunRepository.java @@ -0,0 +1,16 @@ +package org.raddatz.familienarchiv.repository; + +import org.raddatz.familienarchiv.model.OcrTrainingRun; +import org.raddatz.familienarchiv.model.TrainingStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface OcrTrainingRunRepository extends JpaRepository { + + Optional findFirstByStatus(TrainingStatus status); + + List findTop5ByOrderByCreatedAtDesc(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrTrainingService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrTrainingService.java new file mode 100644 index 00000000..1315a2de --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrTrainingService.java @@ -0,0 +1,147 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.OcrTrainingRun; +import org.raddatz.familienarchiv.model.TrainingStatus; +import org.raddatz.familienarchiv.repository.OcrTrainingRunRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; +import org.slf4j.MDC; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.ByteArrayOutputStream; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class OcrTrainingService { + + private final OcrTrainingRunRepository trainingRunRepository; + private final TrainingDataExportService trainingDataExportService; + private final OcrClient ocrClient; + private final OcrHealthClient ocrHealthClient; + private final TranscriptionBlockRepository blockRepository; + + public record TrainingInfoResponse( + int availableBlocks, + int totalOcrBlocks, + int availableDocuments, + boolean ocrServiceAvailable, + OcrTrainingRun lastRun, + List runs + ) {} + + @Transactional + public OcrTrainingRun triggerTraining(UUID triggeredBy) { + if (trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING).isPresent()) { + throw DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING, + "A training run is already in progress"); + } + + var eligibleBlocks = trainingDataExportService.queryEligibleBlocks(); + if (eligibleBlocks.size() < 5) { + throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, + "At least 5 eligible blocks are required to start training (found " + eligibleBlocks.size() + ")"); + } + + long documentCount = eligibleBlocks.stream() + .map(b -> b.getDocumentId()) + .distinct() + .count(); + + OcrTrainingRun run = OcrTrainingRun.builder() + .status(TrainingStatus.RUNNING) + .blockCount(eligibleBlocks.size()) + .documentCount((int) documentCount) + .modelName("german_kurrent") + .triggeredBy(triggeredBy) + .build(); + run = trainingRunRepository.save(run); + + String runId = run.getId().toString(); + MDC.put("trainingRunId", runId); + log.info("Started training run {} with {} blocks from {} documents", + runId, eligibleBlocks.size(), documentCount); + + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + trainingDataExportService.exportToZip().writeTo(baos); + byte[] zipBytes = baos.toByteArray(); + + log.info("[trainingRun={}] Sending {} bytes to OCR service", runId, zipBytes.length); + ocrClient.trainModel(zipBytes); + + run.setStatus(TrainingStatus.DONE); + run.setCompletedAt(Instant.now()); + run = trainingRunRepository.save(run); + log.info("[trainingRun={}] Training completed successfully", runId); + } catch (Exception e) { + run.setStatus(TrainingStatus.FAILED); + run.setErrorMessage(e.getMessage()); + run.setCompletedAt(Instant.now()); + run = trainingRunRepository.save(run); + log.error("[trainingRun={}] Training failed: {}", runId, e.getMessage(), e); + } finally { + MDC.remove("trainingRunId"); + } + + return run; + } + + public TrainingInfoResponse getTrainingInfo() { + var eligibleBlocks = trainingDataExportService.queryEligibleBlocks(); + int availableDocuments = (int) eligibleBlocks.stream() + .map(b -> b.getDocumentId()) + .distinct() + .count(); + + int totalOcrBlocks = blockRepository.findAll().size(); + + List recentRuns = trainingRunRepository.findTop5ByOrderByCreatedAtDesc(); + OcrTrainingRun lastRun = recentRuns.isEmpty() ? null : recentRuns.get(0); + + return new TrainingInfoResponse( + eligibleBlocks.size(), + totalOcrBlocks, + availableDocuments, + ocrHealthClient.isHealthy(), + lastRun, + recentRuns + ); + } + + @EventListener(ApplicationReadyEvent.class) + @Transactional + public void recoverOrphanedRuns() { + var cutoff = Instant.now().minusSeconds(3600); + trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING).ifPresent(run -> { + if (run.getCreatedAt().isBefore(cutoff)) { + run.setStatus(TrainingStatus.FAILED); + run.setErrorMessage("Abgebrochen: Dienst wurde neugestartet"); + run.setCompletedAt(Instant.now()); + trainingRunRepository.save(run); + log.warn("Recovered orphaned training run {} (marked FAILED on startup)", run.getId()); + } + }); + } + + public Map buildTrainingInfoMap(TrainingInfoResponse info) { + return Map.of( + "availableBlocks", info.availableBlocks(), + "totalOcrBlocks", info.totalOcrBlocks(), + "availableDocuments", info.availableDocuments(), + "ocrServiceAvailable", info.ocrServiceAvailable(), + "lastRun", info.lastRun() != null ? info.lastRun() : Map.of(), + "runs", info.runs() + ); + } +} diff --git a/backend/src/main/resources/db/migration/V30__add_ocr_training_runs.sql b/backend/src/main/resources/db/migration/V30__add_ocr_training_runs.sql new file mode 100644 index 00000000..e1420d72 --- /dev/null +++ b/backend/src/main/resources/db/migration/V30__add_ocr_training_runs.sql @@ -0,0 +1,16 @@ +CREATE TABLE ocr_training_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + status VARCHAR(20) NOT NULL DEFAULT 'RUNNING', + block_count INT NOT NULL, + document_count INT NOT NULL, + model_name VARCHAR(100) NOT NULL, + error_message TEXT, + triggered_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ +); + +-- Enforce single active run at the DB layer (application check is the UX layer) +CREATE UNIQUE INDEX idx_ocr_training_runs_one_running + ON ocr_training_runs (status) + WHERE status = 'RUNNING'; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java index bdb3a346..075c18be 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java @@ -44,6 +44,7 @@ class OcrControllerTest { @MockitoBean UserService userService; @MockitoBean CustomUserDetailsService customUserDetailsService; @MockitoBean TrainingDataExportService trainingDataExportService; + @MockitoBean OcrTrainingService ocrTrainingService; @Test @WithMockUser(authorities = "WRITE_ALL") @@ -163,6 +164,68 @@ class OcrControllerTest { result.getResponse().getContentType()).contains("application/zip")); } + // ─── POST /api/ocr/train ─────────────────────────────────────────────────── + + @Test + void triggerTraining_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/ocr/train")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void triggerTraining_returns403_whenNotAdmin() throws Exception { + mockMvc.perform(post("/api/ocr/train")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void triggerTraining_returns409_whenRunAlreadyRunning() throws Exception { + when(ocrTrainingService.triggerTraining(any())) + .thenThrow(DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING, "Already running")); + + mockMvc.perform(post("/api/ocr/train")) + .andExpect(status().isConflict()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void triggerTraining_returns201_withRunInfo() throws Exception { + UUID runId = UUID.randomUUID(); + OcrTrainingRun run = OcrTrainingRun.builder() + .id(runId).status(TrainingStatus.DONE) + .blockCount(10).documentCount(3).modelName("german_kurrent").build(); + when(ocrTrainingService.triggerTraining(any())).thenReturn(run); + + mockMvc.perform(post("/api/ocr/train")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("DONE")) + .andExpect(jsonPath("$.blockCount").value(10)); + } + + // ─── GET /api/ocr/training-info ─────────────────────────────────────────── + + @Test + @WithMockUser(authorities = "READ_ALL") + void getTrainingInfo_returns403_whenNotAdmin() throws Exception { + mockMvc.perform(get("/api/ocr/training-info")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void getTrainingInfo_returns200_withInfo() throws Exception { + OcrTrainingService.TrainingInfoResponse info = + new OcrTrainingService.TrainingInfoResponse(5, 20, 2, true, null, List.of()); + when(ocrTrainingService.getTrainingInfo()).thenReturn(info); + + mockMvc.perform(get("/api/ocr/training-info")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.availableBlocks").value(5)) + .andExpect(jsonPath("$.ocrServiceAvailable").value(true)); + } + @Test @WithMockUser(authorities = "READ_ALL") void getDocumentOcrStatus_returnsNone_whenNoOcrJobExists() throws Exception { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/OcrTrainingServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrTrainingServiceTest.java new file mode 100644 index 00000000..d4aea29c --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrTrainingServiceTest.java @@ -0,0 +1,168 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.OcrTrainingRun; +import org.raddatz.familienarchiv.model.TrainingLabel; +import org.raddatz.familienarchiv.model.TrainingStatus; +import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.raddatz.familienarchiv.repository.OcrTrainingRunRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.*; + +class OcrTrainingServiceTest { + + OcrTrainingRunRepository runRepository; + TrainingDataExportService exportService; + OcrClient ocrClient; + OcrHealthClient healthClient; + TranscriptionBlockRepository blockRepository; + OcrTrainingService service; + + @BeforeEach + void setUp() { + runRepository = mock(OcrTrainingRunRepository.class); + exportService = mock(TrainingDataExportService.class); + ocrClient = mock(OcrClient.class); + healthClient = mock(OcrHealthClient.class); + blockRepository = mock(TranscriptionBlockRepository.class); + + service = new OcrTrainingService(runRepository, exportService, ocrClient, healthClient, blockRepository); + + when(blockRepository.findAll()).thenReturn(List.of()); + when(runRepository.findTop5ByOrderByCreatedAtDesc()).thenReturn(List.of()); + } + + // ─── Concurrent guard ───────────────────────────────────────────────────── + + @Test + void triggerTraining_throws409_whenRunningRunExists() { + when(runRepository.findFirstByStatus(TrainingStatus.RUNNING)) + .thenReturn(Optional.of(OcrTrainingRun.builder() + .id(UUID.randomUUID()).status(TrainingStatus.RUNNING) + .blockCount(5).documentCount(2).modelName("german_kurrent").build())); + + assertThatThrownBy(() -> service.triggerTraining(null)) + .isInstanceOf(DomainException.class) + .extracting("status") + .satisfies(s -> assertThat(s.toString()).contains("409")); + } + + // ─── Threshold guard ────────────────────────────────────────────────────── + + @Test + void triggerTraining_throws422_whenFewerThan5Blocks() { + when(runRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty()); + when(exportService.queryEligibleBlocks()).thenReturn(List.of( + TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(UUID.randomUUID()).build(), + TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(UUID.randomUUID()).build() + )); + + assertThatThrownBy(() -> service.triggerTraining(null)) + .isInstanceOf(DomainException.class); + } + + // ─── Happy path ─────────────────────────────────────────────────────────── + + @Test + void triggerTraining_createsRunWithCorrectCounts_andMarksDone() throws Exception { + when(runRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty()); + + UUID docA = UUID.randomUUID(); + UUID docB = UUID.randomUUID(); + List blocks = List.of( + TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build(), + TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build(), + TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build(), + TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build(), + TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docB).build() + ); + when(exportService.queryEligibleBlocks()).thenReturn(blocks); + when(exportService.exportToZip()).thenReturn(out -> {}); + when(ocrClient.trainModel(any())).thenReturn(new OcrClient.TrainingResult(0.05, 0.95, 3)); + + OcrTrainingRun saved = OcrTrainingRun.builder() + .id(UUID.randomUUID()).status(TrainingStatus.RUNNING) + .blockCount(5).documentCount(2).modelName("german_kurrent").build(); + when(runRepository.save(any())).thenReturn(saved); + + service.triggerTraining(null); + + // Verify run created with correct counts and then updated to DONE + verify(runRepository, times(2)).save(argThat(run -> + run.getBlockCount() == 5 || run.getStatus() == TrainingStatus.DONE)); + } + + // ─── Failure path ───────────────────────────────────────────────────────── + + @Test + void triggerTraining_marksRunFailed_whenOcrClientThrows() throws Exception { + when(runRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty()); + + UUID docA = UUID.randomUUID(); + List blocks = List.of( + TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build(), + TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build(), + TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build(), + TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build(), + TranscriptionBlock.builder().id(UUID.randomUUID()).documentId(docA).build() + ); + when(exportService.queryEligibleBlocks()).thenReturn(blocks); + when(exportService.exportToZip()).thenReturn(out -> {}); + when(ocrClient.trainModel(any())).thenThrow(new RuntimeException("OCR service timeout")); + + OcrTrainingRun saved = OcrTrainingRun.builder() + .id(UUID.randomUUID()).status(TrainingStatus.RUNNING) + .blockCount(5).documentCount(1).modelName("german_kurrent").build(); + when(runRepository.save(any())).thenReturn(saved); + + service.triggerTraining(null); + + verify(runRepository, atLeastOnce()).save(argThat(run -> + run.getStatus() == TrainingStatus.FAILED && run.getErrorMessage() != null)); + } + + // ─── Orphan recovery ────────────────────────────────────────────────────── + + @Test + void recoverOrphanedRuns_marksRunFailed_whenOlderThanOneHour() { + OcrTrainingRun orphan = OcrTrainingRun.builder() + .id(UUID.randomUUID()).status(TrainingStatus.RUNNING) + .blockCount(5).documentCount(1).modelName("german_kurrent") + .createdAt(Instant.now().minusSeconds(7200)) + .build(); + when(runRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.of(orphan)); + when(runRepository.save(any())).thenReturn(orphan); + + service.recoverOrphanedRuns(); + + verify(runRepository).save(argThat(run -> + run.getStatus() == TrainingStatus.FAILED + && run.getErrorMessage().contains("Abgebrochen"))); + } + + @Test + void recoverOrphanedRuns_doesNothing_whenRunIsRecent() { + OcrTrainingRun recent = OcrTrainingRun.builder() + .id(UUID.randomUUID()).status(TrainingStatus.RUNNING) + .blockCount(5).documentCount(1).modelName("german_kurrent") + .createdAt(Instant.now().minusSeconds(60)) + .build(); + when(runRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.of(recent)); + + service.recoverOrphanedRuns(); + + verify(runRepository, never()).save(any()); + } +} -- 2.49.1 From 4e08d31e017ff0e8f724b93cd65adc014c9ce9a8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 13 Apr 2026 14:58:13 +0200 Subject: [PATCH 07/40] 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 @@
-

Kurrent-Erkennung trainieren

-

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

+

{m.training_ocr_heading()}

+

{m.training_ocr_description()}

- {available} geprüfte Blöcke bereit / - {trainingInfo?.availableDocuments ?? 0} Dokumente - (von {trainingInfo?.totalOcrBlocks ?? 0} OCR-Blöcken gesamt) + {m.training_ocr_blocks_ready({ blocks: available, docs: trainingInfo?.availableDocuments ?? 0 })} + {m.training_ocr_blocks_total({ total: trainingInfo?.totalOcrBlocks ?? 0 })}

{#if tooFewBlocks}

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

{:else if serviceDown} -

OCR-Dienst ist nicht erreichbar.

+

{m.training_service_down()}

{/if} {#if successMessage}

{successMessage}

{/if} -

Verlauf

+

+ {m.training_history_heading()} +

diff --git a/frontend/src/lib/components/SegmentationTrainingCard.svelte b/frontend/src/lib/components/SegmentationTrainingCard.svelte new file mode 100644 index 00000000..b1553c94 --- /dev/null +++ b/frontend/src/lib/components/SegmentationTrainingCard.svelte @@ -0,0 +1,86 @@ + + +
+

{m.training_seg_heading()}

+

{m.training_seg_description()}

+ +

+ {m.training_seg_blocks_ready({ blocks: available })} +

+ + + + {#if tooFewBlocks} +

+ {m.training_seg_too_few_blocks({ available })} +

+ {:else if serviceDown} +

{m.training_service_down()}

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

{successMessage}

+ {/if} + +

+ {m.training_history_heading()} +

+ r.modelName === 'blla')} /> +
diff --git a/frontend/src/lib/components/TrainingHistory.svelte b/frontend/src/lib/components/TrainingHistory.svelte index 86f1361c..1260c15d 100644 --- a/frontend/src/lib/components/TrainingHistory.svelte +++ b/frontend/src/lib/components/TrainingHistory.svelte @@ -1,4 +1,6 @@
+ {#if annotationCount > 0} +
+ +

{m.ocr_use_existing_annotations_hint()}

+
+ {/if}