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 4ada18e4..65db519c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java @@ -4,7 +4,10 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.dto.BatchOcrDTO; import org.raddatz.familienarchiv.dto.OcrStatusDTO; +import org.raddatz.familienarchiv.dto.TrainingHistoryResponse; +import org.raddatz.familienarchiv.dto.TrainingInfoResponse; import org.raddatz.familienarchiv.dto.TriggerOcrDTO; +import org.raddatz.familienarchiv.dto.TriggerSenderTrainingDTO; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.OcrJob; import org.raddatz.familienarchiv.model.OcrTrainingRun; @@ -15,6 +18,7 @@ import org.raddatz.familienarchiv.service.OcrProgressService; import org.raddatz.familienarchiv.service.OcrService; import org.raddatz.familienarchiv.service.OcrTrainingService; import org.raddatz.familienarchiv.service.SegmentationTrainingExportService; +import org.raddatz.familienarchiv.service.SenderModelService; import org.raddatz.familienarchiv.service.TrainingDataExportService; import org.raddatz.familienarchiv.service.UserService; import org.springframework.http.HttpHeaders; @@ -42,6 +46,7 @@ public class OcrController { private final TrainingDataExportService trainingDataExportService; private final SegmentationTrainingExportService segmentationTrainingExportService; private final OcrTrainingService ocrTrainingService; + private final SenderModelService senderModelService; @PostMapping("/api/documents/{documentId}/ocr") @ResponseStatus(HttpStatus.ACCEPTED) @@ -130,10 +135,29 @@ public class OcrController { @GetMapping("/api/ocr/training-info") @RequirePermission(Permission.ADMIN) - public OcrTrainingService.TrainingInfoResponse getTrainingInfo() { + public TrainingInfoResponse getTrainingInfo() { return ocrTrainingService.getTrainingInfo(); } + @GetMapping("/api/ocr/training-info/global") + @RequirePermission(Permission.ADMIN) + public TrainingHistoryResponse getGlobalTrainingHistory() { + return ocrTrainingService.getGlobalTrainingHistory(); + } + + @GetMapping("/api/ocr/training-info/{personId}") + @RequirePermission(Permission.ADMIN) + public TrainingHistoryResponse getSenderTrainingHistory(@PathVariable UUID personId) { + return ocrTrainingService.getSenderTrainingHistory(personId); + } + + @PostMapping("/api/ocr/train-sender") + @ResponseStatus(HttpStatus.ACCEPTED) + @RequirePermission(Permission.ADMIN) + public OcrTrainingRun triggerSenderTraining(@Valid @RequestBody TriggerSenderTrainingDTO dto) { + return senderModelService.triggerManualSenderTraining(dto.personId()); + } + private UUID resolveUserId(Authentication authentication) { if (authentication == null || !authentication.isAuthenticated()) return null; try { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/TrainingHistoryResponse.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/TrainingHistoryResponse.java new file mode 100644 index 00000000..10c1f42a --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/TrainingHistoryResponse.java @@ -0,0 +1,11 @@ +package org.raddatz.familienarchiv.dto; + +import org.raddatz.familienarchiv.model.OcrTrainingRun; + +import java.util.List; +import java.util.Map; + +public record TrainingHistoryResponse( + List runs, + Map personNames +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/TrainingInfoResponse.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/TrainingInfoResponse.java new file mode 100644 index 00000000..6f1c6343 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/TrainingInfoResponse.java @@ -0,0 +1,19 @@ +package org.raddatz.familienarchiv.dto; + +import org.raddatz.familienarchiv.model.OcrTrainingRun; +import org.raddatz.familienarchiv.model.SenderModel; + +import java.util.List; +import java.util.Map; + +public record TrainingInfoResponse( + int availableBlocks, + int totalOcrBlocks, + int availableDocuments, + int availableSegBlocks, + boolean ocrServiceAvailable, + OcrTrainingRun lastRun, + List runs, + Map personNames, + List senderModels +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/TriggerSenderTrainingDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/TriggerSenderTrainingDTO.java new file mode 100644 index 00000000..bae2fcb4 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/TriggerSenderTrainingDTO.java @@ -0,0 +1,12 @@ +package org.raddatz.familienarchiv.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.util.UUID; + +public record TriggerSenderTrainingDTO( + @NotNull + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + UUID personId +) {} 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 5d10c917..6027942a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -77,6 +77,8 @@ public enum ErrorCode { OCR_PROCESSING_FAILED, /** A training run is already in progress. 409 */ TRAINING_ALREADY_RUNNING, + /** Internal inconsistency: expected training run row was not found after creation. 500 */ + OCR_TRAINING_CONFLICT, // --- Tags --- /** A tag with the given ID does not exist. 404 */ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrTrainingRunRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrTrainingRunRepository.java index b3878164..fb2e5ca9 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrTrainingRunRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrTrainingRunRepository.java @@ -19,4 +19,8 @@ public interface OcrTrainingRunRepository extends JpaRepository findTop20ByOrderByCreatedAtDesc(); + + List findByPersonIdIsNullOrderByCreatedAtDesc(); + + List findByPersonIdOrderByCreatedAtDesc(UUID personId); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrTrainingService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrTrainingService.java index 20780985..83e5086c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrTrainingService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrTrainingService.java @@ -2,9 +2,12 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.dto.TrainingHistoryResponse; +import org.raddatz.familienarchiv.dto.TrainingInfoResponse; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.OcrTrainingRun; +import org.raddatz.familienarchiv.model.SenderModel; import org.raddatz.familienarchiv.model.TrainingStatus; import org.raddatz.familienarchiv.repository.OcrTrainingRunRepository; import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; @@ -37,17 +40,7 @@ public class OcrTrainingService { private final TranscriptionBlockRepository blockRepository; private final TransactionTemplate txTemplate; private final PersonService personService; - - public record TrainingInfoResponse( - int availableBlocks, - int totalOcrBlocks, - int availableDocuments, - int availableSegBlocks, - boolean ocrServiceAvailable, - OcrTrainingRun lastRun, - List runs, - Map personNames - ) {} + private final SenderModelService senderModelService; private void assertNoRunningTraining() { if (trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING).isPresent()) { @@ -202,14 +195,15 @@ public class OcrTrainingService { List recentRuns = trainingRunRepository.findTop20ByOrderByCreatedAtDesc(); OcrTrainingRun lastRun = recentRuns.isEmpty() ? null : recentRuns.get(0); - List distinctPersonIds = recentRuns.stream() - .map(OcrTrainingRun::getPersonId) - .filter(Objects::nonNull) + List senderModels = senderModelService.getAllSenderModels(); + + List allPersonIds = senderModels.stream() + .map(SenderModel::getPersonId) .distinct() .toList(); Map personNames = new HashMap<>(); - if (!distinctPersonIds.isEmpty()) { - personService.getAllById(distinctPersonIds) + if (!allPersonIds.isEmpty()) { + personService.getAllById(allPersonIds) .forEach(p -> personNames.put(p.getId().toString(), p.getDisplayName())); } @@ -221,10 +215,22 @@ public class OcrTrainingService { ocrHealthClient.isHealthy(), lastRun, recentRuns, - personNames + personNames, + senderModels ); } + public TrainingHistoryResponse getGlobalTrainingHistory() { + List runs = trainingRunRepository.findByPersonIdIsNullOrderByCreatedAtDesc(); + return new TrainingHistoryResponse(runs, Map.of()); + } + + public TrainingHistoryResponse getSenderTrainingHistory(UUID personId) { + String personName = personService.getById(personId).getDisplayName(); + List runs = trainingRunRepository.findByPersonIdOrderByCreatedAtDesc(personId); + return new TrainingHistoryResponse(runs, Map.of(personId.toString(), personName)); + } + @EventListener(ApplicationReadyEvent.class) @Transactional public void recoverOrphanedRuns() { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/SenderModelService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/SenderModelService.java index be9318a7..e7881474 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/SenderModelService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/SenderModelService.java @@ -2,6 +2,8 @@ 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.SenderModel; import org.raddatz.familienarchiv.model.TrainingStatus; @@ -9,7 +11,9 @@ import org.raddatz.familienarchiv.repository.OcrTrainingRunRepository; import org.raddatz.familienarchiv.repository.SenderModelRepository; import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +21,7 @@ import org.springframework.transaction.support.TransactionTemplate; import java.io.ByteArrayOutputStream; import java.time.Instant; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -32,6 +37,12 @@ public class SenderModelService { private final OcrClient ocrClient; private final TransactionTemplate txTemplate; private final TrainingDataExportService trainingDataExportService; + private final PersonService personService; + + // Self-reference through the Spring proxy so @Async is honoured on self-calls. + @Lazy + @Autowired + private SenderModelService self; @Value("${ocr.sender-model.activation-threshold:100}") private int activationThreshold; @@ -45,6 +56,31 @@ public class SenderModelService { .map(SenderModel::getModelPath); } + public List getAllSenderModels() { + return senderModelRepository.findAll(); + } + + public OcrTrainingRun triggerManualSenderTraining(UUID personId) { + personService.getById(personId); + long correctedLines = blockRepository.countManualKurrentBlocksByPerson(personId); + boolean runNow = runOrQueueSenderTraining(personId, (int) correctedLines); + TrainingStatus targetStatus = runNow ? TrainingStatus.RUNNING : TrainingStatus.QUEUED; + OcrTrainingRun run = trainingRunRepository.findFirstByPersonIdAndStatus(personId, targetStatus) + .orElseThrow(() -> DomainException.internal( + ErrorCode.OCR_TRAINING_CONFLICT, + "Expected " + targetStatus + " run for person " + personId)); + if (runNow) { + self.runSenderTraining(personId); + } + return run; + } + + @Async + public void runSenderTraining(UUID personId) { + long correctedLines = blockRepository.countManualKurrentBlocksByPerson(personId); + triggerSenderTraining(personId, (int) correctedLines); + } + /** * Called after every MANUAL block save for HANDWRITING_KURRENT documents. * Checks activation and retrain thresholds; enqueues or starts sender training when met. @@ -118,7 +154,8 @@ public class SenderModelService { OcrTrainingRun run = Objects.requireNonNull(txTemplate.execute(status -> trainingRunRepository.findFirstByPersonIdAndStatus(personId, TrainingStatus.RUNNING) - .orElseThrow(() -> new IllegalStateException( + .orElseThrow(() -> DomainException.internal( + ErrorCode.OCR_TRAINING_CONFLICT, "Expected RUNNING row for person " + personId + " but none found")))); String runId = run.getId().toString(); 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 e65e490c..9fe3b28b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java @@ -5,6 +5,8 @@ import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.config.SecurityConfig; import org.raddatz.familienarchiv.dto.BatchOcrDTO; import org.raddatz.familienarchiv.dto.OcrStatusDTO; +import org.raddatz.familienarchiv.dto.TrainingHistoryResponse; +import org.raddatz.familienarchiv.dto.TrainingInfoResponse; import org.raddatz.familienarchiv.dto.TriggerOcrDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; @@ -28,6 +30,8 @@ import java.util.Map; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -49,6 +53,7 @@ class OcrControllerTest { @MockitoBean TrainingDataExportService trainingDataExportService; @MockitoBean SegmentationTrainingExportService segmentationTrainingExportService; @MockitoBean OcrTrainingService ocrTrainingService; + @MockitoBean SenderModelService senderModelService; @Test @WithMockUser(authorities = "WRITE_ALL") @@ -220,8 +225,7 @@ class OcrControllerTest { @Test @WithMockUser(authorities = "ADMIN") void getTrainingInfo_returns200_withInfo() throws Exception { - OcrTrainingService.TrainingInfoResponse info = - new OcrTrainingService.TrainingInfoResponse(5, 20, 2, 3, true, null, List.of(), Map.of()); + TrainingInfoResponse info = new TrainingInfoResponse(5, 20, 2, 3, true, null, List.of(), Map.of(), List.of()); when(ocrTrainingService.getTrainingInfo()).thenReturn(info); mockMvc.perform(get("/api/ocr/training-info")) @@ -237,9 +241,8 @@ class OcrControllerTest { OcrTrainingRun runWithPerson = OcrTrainingRun.builder() .id(UUID.randomUUID()).status(TrainingStatus.DONE) .personId(personId).blockCount(5).documentCount(1).modelName("sender_x").build(); - OcrTrainingService.TrainingInfoResponse info = - new OcrTrainingService.TrainingInfoResponse(5, 20, 2, 3, true, null, - List.of(runWithPerson), Map.of()); + TrainingInfoResponse info = new TrainingInfoResponse(5, 20, 2, 3, true, null, + List.of(runWithPerson), Map.of(), List.of()); when(ocrTrainingService.getTrainingInfo()).thenReturn(info); mockMvc.perform(get("/api/ocr/training-info")) @@ -254,9 +257,8 @@ class OcrControllerTest { OcrTrainingRun runWithPerson = OcrTrainingRun.builder() .id(UUID.randomUUID()).status(TrainingStatus.DONE) .personId(personId).blockCount(5).documentCount(1).modelName("sender_x").build(); - OcrTrainingService.TrainingInfoResponse info = - new OcrTrainingService.TrainingInfoResponse(5, 20, 2, 3, true, null, - List.of(runWithPerson), Map.of(personId.toString(), "Max Mustermann")); + TrainingInfoResponse info = new TrainingInfoResponse(5, 20, 2, 3, true, null, + List.of(runWithPerson), Map.of(personId.toString(), "Max Mustermann"), List.of()); when(ocrTrainingService.getTrainingInfo()).thenReturn(info); mockMvc.perform(get("/api/ocr/training-info")) @@ -267,8 +269,7 @@ class OcrControllerTest { @Test @WithMockUser(authorities = "ADMIN") void getTrainingInfo_serializes_null_lastRun_as_json_null() throws Exception { - OcrTrainingService.TrainingInfoResponse info = - new OcrTrainingService.TrainingInfoResponse(0, 0, 0, 0, false, null, List.of(), Map.of()); + TrainingInfoResponse info = new TrainingInfoResponse(0, 0, 0, 0, false, null, List.of(), Map.of(), List.of()); when(ocrTrainingService.getTrainingInfo()).thenReturn(info); mockMvc.perform(get("/api/ocr/training-info")) @@ -276,6 +277,177 @@ class OcrControllerTest { .andExpect(jsonPath("$.lastRun").doesNotExist()); } + @Test + @WithMockUser(authorities = "ADMIN") + void getTrainingInfo_returns200_includingSenderModels() throws Exception { + UUID personId = UUID.randomUUID(); + SenderModel senderModel = SenderModel.builder() + .id(UUID.randomUUID()).personId(personId).correctedLinesAtTraining(80).build(); + TrainingInfoResponse info = new TrainingInfoResponse( + 5, 20, 2, 3, true, null, List.of(), Map.of(), List.of(senderModel)); + when(ocrTrainingService.getTrainingInfo()).thenReturn(info); + + mockMvc.perform(get("/api/ocr/training-info")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.senderModels").isArray()) + .andExpect(jsonPath("$.senderModels[0].personId").value(personId.toString())); + } + + // ─── GET /api/ocr/training-info/global ─────────────────────────────────── + + @Test + void getGlobalTrainingHistory_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/ocr/training-info/global")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getGlobalTrainingHistory_returns403_whenNotAdmin() throws Exception { + mockMvc.perform(get("/api/ocr/training-info/global")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void getGlobalTrainingHistory_returns200_withRunsList() throws Exception { + TrainingHistoryResponse history = new TrainingHistoryResponse(List.of(), Map.of()); + when(ocrTrainingService.getGlobalTrainingHistory()).thenReturn(history); + + mockMvc.perform(get("/api/ocr/training-info/global")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.runs").isArray()); + } + + // ─── GET /api/ocr/training-info/{personId} ─────────────────────────────── + + @Test + void getSenderTrainingHistory_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/ocr/training-info/{id}", UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getSenderTrainingHistory_returns403_whenNotAdmin() throws Exception { + mockMvc.perform(get("/api/ocr/training-info/{id}", UUID.randomUUID())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void getSenderTrainingHistory_returns404_whenPersonNotFound() throws Exception { + UUID unknownId = UUID.randomUUID(); + when(ocrTrainingService.getSenderTrainingHistory(unknownId)) + .thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found")); + + mockMvc.perform(get("/api/ocr/training-info/{id}", unknownId)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void getSenderTrainingHistory_returns200_withRunsList() throws Exception { + UUID personId = UUID.randomUUID(); + TrainingHistoryResponse history = new TrainingHistoryResponse(List.of(), Map.of(personId.toString(), "Anna Müller")); + when(ocrTrainingService.getSenderTrainingHistory(personId)).thenReturn(history); + + mockMvc.perform(get("/api/ocr/training-info/{id}", personId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.runs").isArray()) + .andExpect(jsonPath("$.personNames." + personId).value("Anna Müller")); + } + + // ─── POST /api/ocr/train-sender ─────────────────────────────────────────── + + @Test + @WithMockUser(authorities = "ADMIN") + void triggerSenderTraining_returns400_whenPersonIdIsNull() throws Exception { + mockMvc.perform(post("/api/ocr/train-sender") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"personId\":null}")) + .andExpect(status().isBadRequest()); + } + + @Test + void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/ocr/train-sender") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"personId\":\"" + UUID.randomUUID() + "\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void triggerSenderTraining_returns403_whenNotAdmin() throws Exception { + mockMvc.perform(post("/api/ocr/train-sender") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"personId\":\"" + UUID.randomUUID() + "\"}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void triggerSenderTraining_returns404_whenPersonNotFound() throws Exception { + UUID unknownId = UUID.randomUUID(); + when(senderModelService.triggerManualSenderTraining(unknownId)) + .thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found")); + + mockMvc.perform(post("/api/ocr/train-sender") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"personId\":\"" + unknownId + "\"}")) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void triggerSenderTraining_returns202_withRunning_run() throws Exception { + UUID personId = UUID.randomUUID(); + OcrTrainingRun run = OcrTrainingRun.builder() + .id(UUID.randomUUID()).status(TrainingStatus.RUNNING) + .personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build(); + when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run); + + mockMvc.perform(post("/api/ocr/train-sender") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"personId\":\"" + personId + "\"}")) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.status").value("RUNNING")); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void triggerSenderTraining_returns202_withQueued_run_whenAnotherRunning() throws Exception { + UUID personId = UUID.randomUUID(); + OcrTrainingRun run = OcrTrainingRun.builder() + .id(UUID.randomUUID()).status(TrainingStatus.QUEUED) + .personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build(); + when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run); + + mockMvc.perform(post("/api/ocr/train-sender") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"personId\":\"" + personId + "\"}")) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.status").value("QUEUED")); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void triggerSenderTraining_doesNotCallRunSenderTraining_fromController() throws Exception { + UUID personId = UUID.randomUUID(); + OcrTrainingRun run = OcrTrainingRun.builder() + .id(UUID.randomUUID()).status(TrainingStatus.RUNNING) + .personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build(); + when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run); + + mockMvc.perform(post("/api/ocr/train-sender") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"personId\":\"" + personId + "\"}")) + .andExpect(status().isAccepted()); + + verify(senderModelService, never()).runSenderTraining(any()); + } + @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 index 2e09eb69..46142713 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/OcrTrainingServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrTrainingServiceTest.java @@ -2,9 +2,12 @@ package org.raddatz.familienarchiv.service; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.dto.TrainingHistoryResponse; +import org.raddatz.familienarchiv.dto.TrainingInfoResponse; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.OcrTrainingRun; import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.SenderModel; import org.raddatz.familienarchiv.model.TrainingStatus; import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.repository.OcrTrainingRunRepository; @@ -34,6 +37,7 @@ class OcrTrainingServiceTest { TranscriptionBlockRepository blockRepository; TransactionTemplate txTemplate; PersonService personService; + SenderModelService senderModelService; OcrTrainingService service; @BeforeEach @@ -46,6 +50,7 @@ class OcrTrainingServiceTest { blockRepository = mock(TranscriptionBlockRepository.class); txTemplate = mock(TransactionTemplate.class); personService = mock(PersonService.class); + senderModelService = mock(SenderModelService.class); // Execute transaction callbacks inline so unit tests run without a real DataSource when(txTemplate.execute(any())).thenAnswer(inv -> { @@ -53,11 +58,12 @@ class OcrTrainingServiceTest { return callback.doInTransaction(null); }); - service = new OcrTrainingService(runRepository, exportService, segExportService, ocrClient, healthClient, blockRepository, txTemplate, personService); + service = new OcrTrainingService(runRepository, exportService, segExportService, ocrClient, healthClient, blockRepository, txTemplate, personService, senderModelService); when(blockRepository.count()).thenReturn(0L); when(runRepository.findTop20ByOrderByCreatedAtDesc()).thenReturn(List.of()); when(segExportService.querySegmentationBlocks()).thenReturn(List.of()); + when(senderModelService.getAllSenderModels()).thenReturn(List.of()); } // ─── Concurrent guard ───────────────────────────────────────────────────── @@ -236,18 +242,14 @@ class OcrTrainingServiceTest { // ─── getTrainingInfo: batch person name resolution ──────────────────────── @Test - void getTrainingInfo_resolves_person_names_in_single_batch_call() { + void getTrainingInfo_resolves_person_names_from_all_senderModels_in_batch() { UUID personA = UUID.randomUUID(); UUID personB = UUID.randomUUID(); - List runs = List.of( - OcrTrainingRun.builder().id(UUID.randomUUID()).status(TrainingStatus.DONE) - .personId(personA).blockCount(5).documentCount(1).modelName("sender_a").build(), - OcrTrainingRun.builder().id(UUID.randomUUID()).status(TrainingStatus.DONE) - .personId(personB).blockCount(5).documentCount(1).modelName("sender_b").build(), - OcrTrainingRun.builder().id(UUID.randomUUID()).status(TrainingStatus.DONE) - .personId(personA).blockCount(5).documentCount(1).modelName("sender_a").build() + List models = List.of( + SenderModel.builder().id(UUID.randomUUID()).personId(personA).correctedLinesAtTraining(100).build(), + SenderModel.builder().id(UUID.randomUUID()).personId(personB).correctedLinesAtTraining(80).build() ); - when(runRepository.findTop20ByOrderByCreatedAtDesc()).thenReturn(runs); + when(senderModelService.getAllSenderModels()).thenReturn(models); when(exportService.queryEligibleBlocks()).thenReturn(List.of()); Person pa = Person.builder().id(personA).firstName("Anna").lastName("Müller").build(); @@ -256,7 +258,7 @@ class OcrTrainingServiceTest { .thenReturn(List.of(pa, pb)); when(healthClient.isHealthy()).thenReturn(true); - OcrTrainingService.TrainingInfoResponse info = service.getTrainingInfo(); + TrainingInfoResponse info = service.getTrainingInfo(); verify(personService, never()).getById(any()); verify(personService, times(1)).getAllById(any()); @@ -264,6 +266,21 @@ class OcrTrainingServiceTest { assertThat(info.personNames()).containsKey(personB.toString()); } + @Test + void getTrainingInfo_includesSenderModels_inResponse() { + UUID personId = UUID.randomUUID(); + SenderModel model = SenderModel.builder() + .id(UUID.randomUUID()).personId(personId).correctedLinesAtTraining(120).build(); + when(senderModelService.getAllSenderModels()).thenReturn(List.of(model)); + when(exportService.queryEligibleBlocks()).thenReturn(List.of()); + when(healthClient.isHealthy()).thenReturn(false); + + TrainingInfoResponse info = service.getTrainingInfo(); + + assertThat(info.senderModels()).hasSize(1); + assertThat(info.senderModels().get(0).getPersonId()).isEqualTo(personId); + } + // ─── Orphan recovery ────────────────────────────────────────────────────── @Test @@ -296,4 +313,51 @@ class OcrTrainingServiceTest { verify(runRepository, never()).save(any()); } + + // ─── getGlobalTrainingHistory ───────────────────────────────────────────── + + @Test + void getGlobalTrainingHistory_returnsOnlyGlobalRuns() { + List globalRuns = List.of( + OcrTrainingRun.builder().id(UUID.randomUUID()).status(TrainingStatus.DONE) + .blockCount(10).documentCount(2).modelName("german_kurrent").build() + ); + when(runRepository.findByPersonIdIsNullOrderByCreatedAtDesc()).thenReturn(globalRuns); + + TrainingHistoryResponse result = service.getGlobalTrainingHistory(); + + assertThat(result.runs()).hasSize(1); + assertThat(result.personNames()).isEmpty(); + verify(runRepository).findByPersonIdIsNullOrderByCreatedAtDesc(); + } + + // ─── getSenderTrainingHistory ───────────────────────────────────────────── + + @Test + void getSenderTrainingHistory_includesPersonName_andRuns() { + UUID personId = UUID.randomUUID(); + Person person = Person.builder().id(personId).firstName("Anna").lastName("Müller").build(); + when(personService.getById(personId)).thenReturn(person); + + List senderRuns = List.of( + OcrTrainingRun.builder().id(UUID.randomUUID()).status(TrainingStatus.DONE) + .personId(personId).blockCount(5).documentCount(1).modelName("sender_x").build() + ); + when(runRepository.findByPersonIdOrderByCreatedAtDesc(personId)).thenReturn(senderRuns); + + TrainingHistoryResponse result = service.getSenderTrainingHistory(personId); + + assertThat(result.runs()).hasSize(1); + assertThat(result.personNames()).containsKey(personId.toString()); + } + + @Test + void getSenderTrainingHistory_propagates404_whenPersonNotFound() { + UUID unknownId = UUID.randomUUID(); + when(personService.getById(unknownId)) + .thenThrow(DomainException.notFound(org.raddatz.familienarchiv.exception.ErrorCode.PERSON_NOT_FOUND, "not found")); + + assertThatThrownBy(() -> service.getSenderTrainingHistory(unknownId)) + .isInstanceOf(DomainException.class); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/SenderModelServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/SenderModelServiceTest.java index 57318758..4f18701a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/SenderModelServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/SenderModelServiceTest.java @@ -7,6 +7,9 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.raddatz.familienarchiv.model.OcrTrainingRun; import org.raddatz.familienarchiv.model.SenderModel; import org.raddatz.familienarchiv.model.TrainingStatus; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.repository.OcrTrainingRunRepository; import org.raddatz.familienarchiv.repository.SenderModelRepository; import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; @@ -30,6 +33,8 @@ class SenderModelServiceTest { OcrClient ocrClient; TransactionTemplate txTemplate; TrainingDataExportService trainingDataExportService; + PersonService personService; + SenderModelService selfProxy; SenderModelService service; UUID personId = UUID.randomUUID(); @@ -42,6 +47,8 @@ class SenderModelServiceTest { ocrClient = mock(OcrClient.class); txTemplate = mock(TransactionTemplate.class); trainingDataExportService = mock(TrainingDataExportService.class); + personService = mock(PersonService.class); + selfProxy = mock(SenderModelService.class); // Execute transaction callbacks inline so unit tests run without a real DataSource. // lenient: not every test hits the txTemplate path, but the setup is shared. @@ -51,11 +58,44 @@ class SenderModelServiceTest { }); service = new SenderModelService(senderModelRepository, blockRepository, - trainingRunRepository, ocrClient, txTemplate, trainingDataExportService); + trainingRunRepository, ocrClient, txTemplate, trainingDataExportService, personService); + ReflectionTestUtils.setField(service, "self", selfProxy); ReflectionTestUtils.setField(service, "activationThreshold", 100); ReflectionTestUtils.setField(service, "retrainDelta", 50); } + // ─── getAllSenderModels ─────────────────────────────────────────────────── + + @Test + void getAllSenderModels_returnsAllModelsFromRepository() { + SenderModel model = SenderModel.builder() + .id(UUID.randomUUID()).personId(personId).correctedLinesAtTraining(100).build(); + when(senderModelRepository.findAll()).thenReturn(java.util.List.of(model)); + + java.util.List result = service.getAllSenderModels(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getPersonId()).isEqualTo(personId); + } + + // ─── runSenderTraining ──────────────────────────────────────────────────── + + @Test + void runSenderTraining_queriesBlockCountForPerson() { + when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(42L); + // triggerSenderTraining needs a RUNNING row — return empty to abort early + when(trainingRunRepository.findFirstByPersonIdAndStatus(personId, TrainingStatus.RUNNING)) + .thenReturn(Optional.empty()); + + try { + service.runSenderTraining(personId); + } catch (Exception ignored) { + // triggerSenderTraining will throw when no RUNNING row found + } + + verify(blockRepository).countManualKurrentBlocksByPerson(personId); + } + // ─── Activation threshold ───────────────────────────────────────────────── @Test @@ -257,8 +297,88 @@ class SenderModelServiceTest { verify(senderModelRepository, never()).save(any()); } + @Test + void triggerSenderTraining_throwsDomainException_whenRunningRowMissingAfterDispatch() { + when(trainingRunRepository.findFirstByPersonIdAndStatus(personId, TrainingStatus.RUNNING)) + .thenReturn(Optional.empty()); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> service.triggerSenderTraining(personId, 0)) + .isInstanceOf(DomainException.class); + } + // ─── triggerSenderTraining — queue promotion ────────────────────────────── + // ─── triggerManualSenderTraining ────────────────────────────────────────── + + @Test + void triggerManualSenderTraining_returnsRunningRun_whenIdle() { + when(personService.getById(personId)).thenReturn(Person.builder().id(personId).build()); + when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(0L); + when(trainingRunRepository.existsByPersonIdAndStatus(personId, TrainingStatus.QUEUED)).thenReturn(false); + when(trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty()); + OcrTrainingRun runningRun = OcrTrainingRun.builder() + .id(UUID.randomUUID()).status(TrainingStatus.RUNNING) + .personId(personId).blockCount(0).documentCount(0).modelName("sender_" + personId).build(); + when(trainingRunRepository.save(any())).thenReturn(runningRun); + when(trainingRunRepository.findFirstByPersonIdAndStatus(personId, TrainingStatus.RUNNING)) + .thenReturn(Optional.of(runningRun)); + + OcrTrainingRun result = service.triggerManualSenderTraining(personId); + + assertThat(result.getStatus()).isEqualTo(TrainingStatus.RUNNING); + assertThat(result.getPersonId()).isEqualTo(personId); + } + + @Test + void triggerManualSenderTraining_returnsQueuedRun_whenAnotherRunning() { + when(personService.getById(personId)).thenReturn(Person.builder().id(personId).build()); + when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(0L); + when(trainingRunRepository.existsByPersonIdAndStatus(personId, TrainingStatus.QUEUED)).thenReturn(false); + when(trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn( + Optional.of(OcrTrainingRun.builder().id(UUID.randomUUID()).status(TrainingStatus.RUNNING) + .blockCount(1).documentCount(0).modelName("german_kurrent").build())); + OcrTrainingRun queuedRun = OcrTrainingRun.builder() + .id(UUID.randomUUID()).status(TrainingStatus.QUEUED) + .personId(personId).blockCount(0).documentCount(0).modelName("sender_" + personId).build(); + when(trainingRunRepository.save(any())).thenReturn(queuedRun); + when(trainingRunRepository.findFirstByPersonIdAndStatus(personId, TrainingStatus.QUEUED)) + .thenReturn(Optional.of(queuedRun)); + + OcrTrainingRun result = service.triggerManualSenderTraining(personId); + + assertThat(result.getStatus()).isEqualTo(TrainingStatus.QUEUED); + } + + @Test + void triggerManualSenderTraining_throws404_whenPersonNotFound() { + when(personService.getById(personId)) + .thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found")); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> service.triggerManualSenderTraining(personId)) + .isInstanceOf(DomainException.class); + } + + @Test + void triggerManualSenderTraining_throwsDomainException_whenRunRowMissingAfterCreate() { + when(personService.getById(personId)).thenReturn(Person.builder().id(personId).build()); + when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(0L); + when(trainingRunRepository.existsByPersonIdAndStatus(personId, TrainingStatus.QUEUED)).thenReturn(false); + when(trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty()); + OcrTrainingRun runningRun = OcrTrainingRun.builder() + .id(UUID.randomUUID()).status(TrainingStatus.RUNNING) + .personId(personId).blockCount(0).documentCount(0).modelName("sender_" + personId).build(); + when(trainingRunRepository.save(any())).thenReturn(runningRun); + // Simulate the run row not being found after creation (defensive path) + when(trainingRunRepository.findFirstByPersonIdAndStatus(personId, TrainingStatus.RUNNING)) + .thenReturn(Optional.empty()); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> service.triggerManualSenderTraining(personId)) + .isInstanceOf(DomainException.class); + } + @Test void triggerSenderTraining_promotesNextQueued_afterCompletion() throws Exception { UUID nextPersonId = UUID.randomUUID(); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 3354ac57..5417f747 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -299,6 +299,23 @@ "history_field_receivers": "Empfänger", "history_field_tags": "Schlagworte", "admin_tab_system": "System", + "admin_tab_ocr": "OCR", + "ocr_status_online": "Online", + "ocr_status_offline": "Offline", + "ocr_stat_training_blocks": "Bereit (OCR-Training)", + "ocr_stat_total_blocks": "Textblöcke gesamt", + "ocr_stat_documents": "Trainingsdokumente", + "ocr_stat_seg_blocks": "Bereit (Segm.-Training)", + "ocr_table_person": "Person", + "ocr_table_cer": "ZFR", + "ocr_table_accuracy": "Genauigkeit", + "ocr_table_lines": "Zeilen", + "ocr_table_actions": "Aktionen", + "ocr_table_details": "Details", + "ocr_no_models": "Noch keine Sender-Modelle trainiert.", + "ocr_sender_models_heading": "Sender-Modelle", + "ocr_global_history_link": "Globaler Verlauf →", + "ocr_global_history_heading": "Globaler Verlauf", "admin_system_backfill_heading": "Verlaufsdaten auffüllen", "admin_system_backfill_description": "Erstellt einen initialen Verlaufseintrag für alle Dokumente, die noch keinen Verlauf haben (z.B. importierte Dokumente). Dadurch werden beim nächsten Bearbeiten nur die tatsächlich geänderten Felder hervorgehoben.", "admin_system_backfill_btn": "Jetzt auffüllen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 4c338b66..b2e76304 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -299,6 +299,23 @@ "history_field_receivers": "Receivers", "history_field_tags": "Tags", "admin_tab_system": "System", + "admin_tab_ocr": "OCR", + "ocr_status_online": "Online", + "ocr_status_offline": "Offline", + "ocr_stat_training_blocks": "Ready (OCR training)", + "ocr_stat_total_blocks": "Total text blocks", + "ocr_stat_documents": "Training documents", + "ocr_stat_seg_blocks": "Ready (seg. training)", + "ocr_table_person": "Person", + "ocr_table_cer": "CER", + "ocr_table_accuracy": "Accuracy", + "ocr_table_lines": "Lines", + "ocr_table_actions": "Actions", + "ocr_table_details": "Details", + "ocr_no_models": "No sender models trained yet.", + "ocr_sender_models_heading": "Sender Models", + "ocr_global_history_link": "Global history →", + "ocr_global_history_heading": "Global History", "admin_system_backfill_heading": "Backfill history data", "admin_system_backfill_description": "Creates an initial history entry for all documents that do not have one yet (e.g. imported documents). This ensures that future edits only highlight actually changed fields.", "admin_system_backfill_btn": "Backfill now", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 529a7f64..96f9efc8 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -299,6 +299,23 @@ "history_field_receivers": "Destinatarios", "history_field_tags": "Etiquetas", "admin_tab_system": "Sistema", + "admin_tab_ocr": "OCR", + "ocr_status_online": "En línea", + "ocr_status_offline": "Sin conexión", + "ocr_stat_training_blocks": "Listos (entrenamiento OCR)", + "ocr_stat_total_blocks": "Total de bloques de texto", + "ocr_stat_documents": "Documentos de entrenamiento", + "ocr_stat_seg_blocks": "Listos (entrenamiento segm.)", + "ocr_table_person": "Persona", + "ocr_table_cer": "TCE", + "ocr_table_accuracy": "Precisión", + "ocr_table_lines": "Líneas", + "ocr_table_actions": "Acciones", + "ocr_table_details": "Detalles", + "ocr_no_models": "Aún no hay modelos de remitente entrenados.", + "ocr_sender_models_heading": "Modelos de remitente", + "ocr_global_history_link": "Historial global →", + "ocr_global_history_heading": "Historial global", "admin_system_backfill_heading": "Completar datos de historial", "admin_system_backfill_description": "Crea una entrada de historial inicial para todos los documentos que aún no tienen ninguna (p.ej. documentos importados). Así, en la próxima edición solo se resaltarán los campos realmente modificados.", "admin_system_backfill_btn": "Completar ahora", diff --git a/frontend/src/lib/components/SegmentationTrainingCard.svelte b/frontend/src/lib/components/SegmentationTrainingCard.svelte index 00a6d9ff..eba98409 100644 --- a/frontend/src/lib/components/SegmentationTrainingCard.svelte +++ b/frontend/src/lib/components/SegmentationTrainingCard.svelte @@ -1,23 +1,12 @@ + +
+ + + {ocrServiceAvailable ? m.ocr_status_online() : m.ocr_status_offline()} + +
diff --git a/frontend/src/routes/admin/ocr/OcrHealthBar.svelte.spec.ts b/frontend/src/routes/admin/ocr/OcrHealthBar.svelte.spec.ts new file mode 100644 index 00000000..486cfa05 --- /dev/null +++ b/frontend/src/routes/admin/ocr/OcrHealthBar.svelte.spec.ts @@ -0,0 +1,19 @@ +import { afterEach, describe, it, expect } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import OcrHealthBar from './OcrHealthBar.svelte'; + +afterEach(cleanup); + +describe('OcrHealthBar', () => { + it('shows online status when OCR service is available', async () => { + render(OcrHealthBar, { ocrServiceAvailable: true }); + const dot = document.querySelector('[role="img"]'); + expect(dot?.className).toContain('bg-green-500'); + }); + + it('shows offline status when OCR service is unavailable', async () => { + render(OcrHealthBar, { ocrServiceAvailable: false }); + const dot = document.querySelector('[role="img"]'); + expect(dot?.className).toContain('bg-red-500'); + }); +}); diff --git a/frontend/src/routes/admin/ocr/OcrModelsTable.svelte b/frontend/src/routes/admin/ocr/OcrModelsTable.svelte new file mode 100644 index 00000000..c99f54e4 --- /dev/null +++ b/frontend/src/routes/admin/ocr/OcrModelsTable.svelte @@ -0,0 +1,78 @@ + + +
+ + + + + + + + + + + + {#each senderModels as model (model.id)} + + + + + + + + {:else} + + + + {/each} + +
{m.ocr_table_person()}{m.ocr_table_cer()}{m.ocr_table_accuracy()}{m.ocr_table_lines()}{m.ocr_table_actions()}
+ + {personNames[model.personId] ?? model.personId} + + + {model.cer != null ? (model.cer * 100).toFixed(1) + '%' : '—'} + + {model.accuracy != null ? (model.accuracy * 100).toFixed(1) + '%' : '—'} + + {model.correctedLinesAtTraining} + + {m.ocr_table_details()} +
+ {m.ocr_no_models()} +
+
diff --git a/frontend/src/routes/admin/ocr/OcrModelsTable.svelte.spec.ts b/frontend/src/routes/admin/ocr/OcrModelsTable.svelte.spec.ts new file mode 100644 index 00000000..160458dd --- /dev/null +++ b/frontend/src/routes/admin/ocr/OcrModelsTable.svelte.spec.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, it, expect } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import OcrModelsTable from './OcrModelsTable.svelte'; +import type { components } from '$lib/generated/api'; + +afterEach(cleanup); + +type SenderModel = components['schemas']['SenderModel']; + +const personId = '123e4567-e89b-12d3-a456-426614174000'; +const model: SenderModel = { + id: 'aaa00000-0000-0000-0000-000000000001', + personId, + correctedLinesAtTraining: 120, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-06-01T00:00:00Z', + cer: 0.04, + accuracy: 0.96 +}; + +describe('OcrModelsTable', () => { + it('shows person name when provided', async () => { + render(OcrModelsTable, { + senderModels: [model], + personNames: { [personId]: 'Anna Müller' } + }); + await expect.element(page.getByText('Anna Müller')).toBeInTheDocument(); + }); + + it('shows person ID when name is missing', async () => { + render(OcrModelsTable, { + senderModels: [model], + personNames: {} + }); + await expect.element(page.getByText(personId)).toBeInTheDocument(); + }); + + it('shows empty state row when no models', async () => { + render(OcrModelsTable, { senderModels: [], personNames: {} }); + const rows = document.querySelectorAll('tbody tr'); + expect(rows.length).toBe(1); + expect(rows[0].querySelector('td[colspan="5"]')).not.toBeNull(); + }); +}); diff --git a/frontend/src/routes/admin/ocr/OcrStatCards.svelte b/frontend/src/routes/admin/ocr/OcrStatCards.svelte new file mode 100644 index 00000000..a34c8f46 --- /dev/null +++ b/frontend/src/routes/admin/ocr/OcrStatCards.svelte @@ -0,0 +1,44 @@ + + +
+
+
{availableBlocks}
+
+ {m.ocr_stat_training_blocks()} +
+
+
+
{totalOcrBlocks}
+
+ {m.ocr_stat_total_blocks()} +
+
+
+
{availableDocuments}
+
+ {m.ocr_stat_documents()} +
+
+
+
{availableSegBlocks}
+
+ {m.ocr_stat_seg_blocks()} +
+
+
diff --git a/frontend/src/routes/admin/ocr/OcrStatCards.svelte.spec.ts b/frontend/src/routes/admin/ocr/OcrStatCards.svelte.spec.ts new file mode 100644 index 00000000..83231b19 --- /dev/null +++ b/frontend/src/routes/admin/ocr/OcrStatCards.svelte.spec.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, it, expect } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import OcrStatCards from './OcrStatCards.svelte'; + +afterEach(cleanup); + +const stats = { + availableBlocks: 42, + totalOcrBlocks: 200, + availableDocuments: 15, + availableSegBlocks: 8 +}; + +describe('OcrStatCards', () => { + it('shows available block count', async () => { + render(OcrStatCards, stats); + await expect.element(page.getByText('42')).toBeInTheDocument(); + }); + + it('shows total OCR block count', async () => { + render(OcrStatCards, stats); + await expect.element(page.getByText('200')).toBeInTheDocument(); + }); + + it('shows available document count', async () => { + render(OcrStatCards, stats); + await expect.element(page.getByText('15')).toBeInTheDocument(); + }); + + it('shows segmentation block count', async () => { + render(OcrStatCards, stats); + await expect.element(page.getByText('8')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/admin/ocr/[personId]/+page.server.ts b/frontend/src/routes/admin/ocr/[personId]/+page.server.ts new file mode 100644 index 00000000..a95076c5 --- /dev/null +++ b/frontend/src/routes/admin/ocr/[personId]/+page.server.ts @@ -0,0 +1,18 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { createApiClient } from '$lib/api.server'; +import { getErrorMessage } from '$lib/errors'; + +export const load: PageServerLoad = async ({ params, fetch }) => { + const api = createApiClient(fetch); + const result = await api.GET('/api/ocr/training-info/{personId}', { + params: { path: { personId: params.personId } } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + throw error(result.response.status, getErrorMessage(code)); + } + + return { history: result.data!, personId: params.personId }; +}; diff --git a/frontend/src/routes/admin/ocr/[personId]/+page.svelte b/frontend/src/routes/admin/ocr/[personId]/+page.svelte new file mode 100644 index 00000000..d6afbd7a --- /dev/null +++ b/frontend/src/routes/admin/ocr/[personId]/+page.svelte @@ -0,0 +1,39 @@ + + +
+
+
+ + + {m.admin_tab_ocr()} + +

+ {personName} +

+
+ +
+ +
+
+
diff --git a/frontend/src/routes/admin/ocr/[personId]/page.server.spec.ts b/frontend/src/routes/admin/ocr/[personId]/page.server.spec.ts new file mode 100644 index 00000000..5e939a4a --- /dev/null +++ b/frontend/src/routes/admin/ocr/[personId]/page.server.spec.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { load } from './+page.server'; + +const mockApi = { GET: vi.fn() }; + +vi.mock('$lib/api.server', () => ({ createApiClient: () => mockApi })); + +beforeEach(() => vi.clearAllMocks()); + +describe('admin/ocr/[personId] — load', () => { + it('returns sender history from API', async () => { + const personId = '123e4567-e89b-12d3-a456-426614174000'; + mockApi.GET.mockResolvedValue({ + response: { ok: true }, + data: { runs: [], personNames: { [personId]: 'Anna Müller' } } + }); + + const result = (await load({ params: { personId }, fetch } as never))!; + + expect(result.history.personNames?.[personId]).toBe('Anna Müller'); + }); + + it('throws 404 when person not found', async () => { + mockApi.GET.mockResolvedValue({ + response: { ok: false, status: 404 }, + error: { code: 'PERSON_NOT_FOUND' } + }); + + await expect( + load({ params: { personId: 'unknown-id' }, fetch } as never) + ).rejects.toMatchObject({ status: 404 }); + }); +}); diff --git a/frontend/src/routes/admin/ocr/[personId]/page.svelte.spec.ts b/frontend/src/routes/admin/ocr/[personId]/page.svelte.spec.ts new file mode 100644 index 00000000..d06445db --- /dev/null +++ b/frontend/src/routes/admin/ocr/[personId]/page.svelte.spec.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, it, expect } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import Page from './+page.svelte'; + +afterEach(cleanup); + +describe('[personId] page', () => { + it('shows person name from personNames map', async () => { + const personId = '123e4567-e89b-12d3-a456-426614174000'; + render(Page, { + data: { + personId, + history: { + runs: [], + personNames: { [personId]: 'Anna Müller' } + } + } + }); + await expect.element(page.getByRole('heading', { level: 1 })).toHaveTextContent('Anna Müller'); + }); + + it('falls back to Unknown when person name is missing', async () => { + const personId = '123e4567-e89b-12d3-a456-426614174000'; + render(Page, { + data: { + personId, + history: { + runs: [], + personNames: {} + } + } + }); + await expect.element(page.getByRole('heading', { level: 1 })).toHaveTextContent('Unknown'); + }); +}); diff --git a/frontend/src/routes/admin/ocr/global/+page.server.ts b/frontend/src/routes/admin/ocr/global/+page.server.ts new file mode 100644 index 00000000..fb1d8dfc --- /dev/null +++ b/frontend/src/routes/admin/ocr/global/+page.server.ts @@ -0,0 +1,16 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { createApiClient } from '$lib/api.server'; +import { getErrorMessage } from '$lib/errors'; + +export const load: PageServerLoad = async ({ fetch }) => { + const api = createApiClient(fetch); + const result = await api.GET('/api/ocr/training-info/global'); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + throw error(result.response.status, getErrorMessage(code)); + } + + return { history: result.data! }; +}; diff --git a/frontend/src/routes/admin/ocr/global/+page.svelte b/frontend/src/routes/admin/ocr/global/+page.svelte new file mode 100644 index 00000000..b0991a4e --- /dev/null +++ b/frontend/src/routes/admin/ocr/global/+page.svelte @@ -0,0 +1,38 @@ + + +
+
+
+ + + {m.admin_tab_ocr()} + +

+ {m.ocr_global_history_heading()} +

+
+ +
+ +
+
+
diff --git a/frontend/src/routes/admin/ocr/global/page.server.spec.ts b/frontend/src/routes/admin/ocr/global/page.server.spec.ts new file mode 100644 index 00000000..cbc78eb0 --- /dev/null +++ b/frontend/src/routes/admin/ocr/global/page.server.spec.ts @@ -0,0 +1,27 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { load } from './+page.server'; + +const mockApi = { GET: vi.fn() }; + +vi.mock('$lib/api.server', () => ({ createApiClient: () => mockApi })); + +beforeEach(() => vi.clearAllMocks()); + +describe('admin/ocr/global — load', () => { + it('returns history from API', async () => { + mockApi.GET.mockResolvedValue({ + response: { ok: true }, + data: { runs: [{ id: 'run1' }], personNames: {} } + }); + + const result = (await load({ fetch } as never))!; + + expect(result.history.runs).toHaveLength(1); + }); + + it('throws error when API call fails', async () => { + mockApi.GET.mockResolvedValue({ response: { ok: false, status: 500 }, error: {} }); + + await expect(load({ fetch } as never)).rejects.toMatchObject({ status: 500 }); + }); +}); diff --git a/frontend/src/routes/admin/ocr/page.server.spec.ts b/frontend/src/routes/admin/ocr/page.server.spec.ts new file mode 100644 index 00000000..b3b27a03 --- /dev/null +++ b/frontend/src/routes/admin/ocr/page.server.spec.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { load } from './+page.server'; + +const mockApi = { GET: vi.fn() }; + +vi.mock('$lib/api.server', () => ({ createApiClient: () => mockApi })); + +beforeEach(() => vi.clearAllMocks()); + +describe('admin/ocr — load', () => { + it('returns trainingInfo from API', async () => { + mockApi.GET.mockResolvedValue({ + response: { ok: true }, + data: { availableBlocks: 10, ocrServiceAvailable: true, senderModels: [] } + }); + + const result = (await load({ fetch } as never))!; + + expect(result.trainingInfo.availableBlocks).toBe(10); + expect(result.trainingInfo.ocrServiceAvailable).toBe(true); + }); + + it('throws 503 when OCR API call fails', async () => { + mockApi.GET.mockResolvedValue({ response: { ok: false, status: 503 }, error: {} }); + + await expect(load({ fetch } as never)).rejects.toMatchObject({ status: 503 }); + }); +}); diff --git a/frontend/src/routes/admin/system/+page.svelte b/frontend/src/routes/admin/system/+page.svelte index adb7c4e3..2bc02c83 100644 --- a/frontend/src/routes/admin/system/+page.svelte +++ b/frontend/src/routes/admin/system/+page.svelte @@ -1,13 +1,6 @@