feat(admin): OCR admin pages — overview & model detail #265
@@ -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 {
|
||||
|
||||
@@ -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<OcrTrainingRun> runs,
|
||||
Map<String, String> personNames
|
||||
) {}
|
||||
@@ -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<OcrTrainingRun> runs,
|
||||
Map<String, String> personNames,
|
||||
List<SenderModel> senderModels
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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 */
|
||||
|
||||
@@ -19,4 +19,8 @@ public interface OcrTrainingRunRepository extends JpaRepository<OcrTrainingRun,
|
||||
boolean existsByPersonIdAndStatus(UUID personId, TrainingStatus status);
|
||||
|
||||
List<OcrTrainingRun> findTop20ByOrderByCreatedAtDesc();
|
||||
|
||||
List<OcrTrainingRun> findByPersonIdIsNullOrderByCreatedAtDesc();
|
||||
|
||||
List<OcrTrainingRun> findByPersonIdOrderByCreatedAtDesc(UUID personId);
|
||||
}
|
||||
|
||||
@@ -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<OcrTrainingRun> runs,
|
||||
Map<String, String> personNames
|
||||
) {}
|
||||
private final SenderModelService senderModelService;
|
||||
|
||||
private void assertNoRunningTraining() {
|
||||
if (trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING).isPresent()) {
|
||||
@@ -202,14 +195,15 @@ public class OcrTrainingService {
|
||||
List<OcrTrainingRun> recentRuns = trainingRunRepository.findTop20ByOrderByCreatedAtDesc();
|
||||
OcrTrainingRun lastRun = recentRuns.isEmpty() ? null : recentRuns.get(0);
|
||||
|
||||
List<UUID> distinctPersonIds = recentRuns.stream()
|
||||
.map(OcrTrainingRun::getPersonId)
|
||||
.filter(Objects::nonNull)
|
||||
List<SenderModel> senderModels = senderModelService.getAllSenderModels();
|
||||
|
||||
List<UUID> allPersonIds = senderModels.stream()
|
||||
.map(SenderModel::getPersonId)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<String, String> 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<OcrTrainingRun> runs = trainingRunRepository.findByPersonIdIsNullOrderByCreatedAtDesc();
|
||||
return new TrainingHistoryResponse(runs, Map.of());
|
||||
}
|
||||
|
||||
public TrainingHistoryResponse getSenderTrainingHistory(UUID personId) {
|
||||
String personName = personService.getById(personId).getDisplayName();
|
||||
List<OcrTrainingRun> runs = trainingRunRepository.findByPersonIdOrderByCreatedAtDesc(personId);
|
||||
return new TrainingHistoryResponse(runs, Map.of(personId.toString(), personName));
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@Transactional
|
||||
public void recoverOrphanedRuns() {
|
||||
|
||||
@@ -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<SenderModel> 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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<OcrTrainingRun> 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<SenderModel> 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<OcrTrainingRun> 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<OcrTrainingRun> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SenderModel> 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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
<script lang="ts">
|
||||
import TrainingHistory from './TrainingHistory.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Run {
|
||||
id: string;
|
||||
status: 'RUNNING' | 'DONE' | 'FAILED';
|
||||
blockCount: number;
|
||||
documentCount: number;
|
||||
modelName: string;
|
||||
errorMessage?: string;
|
||||
triggeredBy?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
import type { TrainingRun } from '$lib/types/training.js';
|
||||
|
||||
interface TrainingInfo {
|
||||
availableSegBlocks?: number;
|
||||
ocrServiceAvailable?: boolean;
|
||||
runs?: Run[];
|
||||
runs?: TrainingRun[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -82,5 +71,8 @@ async function startTraining() {
|
||||
<h3 class="mt-6 mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.training_history_heading()}
|
||||
</h3>
|
||||
<TrainingHistory runs={(trainingInfo?.runs ?? []).filter((r) => r.modelName === 'blla')} />
|
||||
<TrainingHistory
|
||||
runs={(trainingInfo?.runs ?? []).filter((r) => r.modelName === 'blla')}
|
||||
showPersonColumns={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,10 @@ import type { TrainingRun } from '$lib/types/training.js';
|
||||
interface Props {
|
||||
runs: TrainingRun[];
|
||||
personNames?: Record<string, string>;
|
||||
showPersonColumns?: boolean;
|
||||
}
|
||||
|
||||
let { runs, personNames }: Props = $props();
|
||||
let { runs, personNames, showPersonColumns = true }: Props = $props();
|
||||
|
||||
const COLLAPSED_COUNT = 3;
|
||||
let expanded = $state(false);
|
||||
@@ -36,8 +37,10 @@ function formatCer(cer: number | undefined | null): string {
|
||||
<tr class="border-b border-line text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
<th class="pb-2 text-left">{m.training_history_col_date()}</th>
|
||||
<th class="pb-2 text-left">{m.training_history_col_status()}</th>
|
||||
{#if showPersonColumns}
|
||||
<th class="hidden pb-2 text-left md:table-cell">{m.training_col_type()}</th>
|
||||
<th class="hidden pb-2 text-left md:table-cell">{m.training_col_person()}</th>
|
||||
{/if}
|
||||
<th class="pb-2 text-right">{m.training_history_col_blocks()}</th>
|
||||
<th class="hidden pb-2 text-right md:table-cell">{m.training_history_col_docs()}</th>
|
||||
<th class="hidden pb-2 text-right md:table-cell">{m.training_history_col_cer()}</th>
|
||||
@@ -46,7 +49,7 @@ function formatCer(cer: number | undefined | null): string {
|
||||
<tbody id="training-history-rows">
|
||||
{#if runs.length === 0}
|
||||
<tr>
|
||||
<td colspan="7" class="py-4 text-center text-sm text-ink-2">
|
||||
<td colspan={showPersonColumns ? 7 : 5} class="py-4 text-center text-sm text-ink-2">
|
||||
{m.training_history_empty()}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -117,18 +120,20 @@ function formatCer(cer: number | undefined | null): string {
|
||||
{m.training_status_running()}
|
||||
</span>
|
||||
{/if}
|
||||
{#if run.personId && personNames?.[run.personId]}
|
||||
{#if showPersonColumns && run.personId && personNames?.[run.personId]}
|
||||
<span class="mt-0.5 block text-xs text-ink-3 md:hidden"
|
||||
>{personNames[run.personId]}</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
{#if showPersonColumns}
|
||||
<td class="hidden py-2 text-left text-ink-2 md:table-cell">
|
||||
{run.personId ? m.training_type_personalized() : m.training_type_base()}
|
||||
</td>
|
||||
<td class="hidden py-2 text-left text-ink-2 md:table-cell">
|
||||
{run.personId && personNames?.[run.personId] ? personNames[run.personId] : '—'}
|
||||
</td>
|
||||
{/if}
|
||||
<td class="py-2 text-right text-ink-2">{run.blockCount}</td>
|
||||
<td class="hidden py-2 text-right text-ink-2 md:table-cell">{run.documentCount}</td>
|
||||
<td class="hidden py-2 text-right md:table-cell"
|
||||
|
||||
@@ -27,6 +27,7 @@ export type ErrorCode =
|
||||
| 'OCR_DOCUMENT_NOT_UPLOADED'
|
||||
| 'OCR_PROCESSING_FAILED'
|
||||
| 'TRAINING_ALREADY_RUNNING'
|
||||
| 'OCR_TRAINING_CONFLICT'
|
||||
| 'INVALID_TAG_COLOR'
|
||||
| 'TAG_CYCLE_DETECTED'
|
||||
| 'TAG_NOT_FOUND'
|
||||
@@ -105,6 +106,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_ocr_processing_failed();
|
||||
case 'TRAINING_ALREADY_RUNNING':
|
||||
return m.error_training_already_running();
|
||||
case 'OCR_TRAINING_CONFLICT':
|
||||
return m.error_internal_error();
|
||||
case 'INVALID_TAG_COLOR':
|
||||
return m.error_invalid_tag_color();
|
||||
case 'TAG_CYCLE_DETECTED':
|
||||
|
||||
@@ -260,6 +260,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/ocr/train-sender": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["triggerSenderTraining"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/ocr/segtrain": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -852,6 +868,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/ocr/training-info/{personId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getSenderTrainingHistory"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/ocr/training-info/global": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getGlobalTrainingHistory"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/ocr/training-data/export": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1422,6 +1470,10 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
completedAt?: string;
|
||||
};
|
||||
TriggerSenderTrainingDTO: {
|
||||
/** Format: uuid */
|
||||
personId?: string;
|
||||
};
|
||||
BatchOcrDTO: {
|
||||
documentIds: string[];
|
||||
};
|
||||
@@ -1633,6 +1685,45 @@ export interface components {
|
||||
notes?: string;
|
||||
personType?: string;
|
||||
};
|
||||
SenderModel: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: uuid */
|
||||
personId: string;
|
||||
/** Format: double */
|
||||
accuracy?: number;
|
||||
/** Format: double */
|
||||
cer?: number;
|
||||
/** Format: int32 */
|
||||
correctedLinesAtTraining: number;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
};
|
||||
TrainingInfoResponse: {
|
||||
/** Format: int32 */
|
||||
availableBlocks?: number;
|
||||
/** Format: int32 */
|
||||
totalOcrBlocks?: number;
|
||||
/** Format: int32 */
|
||||
availableDocuments?: number;
|
||||
/** Format: int32 */
|
||||
availableSegBlocks?: number;
|
||||
ocrServiceAvailable?: boolean;
|
||||
lastRun?: components["schemas"]["OcrTrainingRun"];
|
||||
runs?: components["schemas"]["OcrTrainingRun"][];
|
||||
personNames?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
senderModels?: components["schemas"]["SenderModel"][];
|
||||
};
|
||||
TrainingHistoryResponse: {
|
||||
runs?: components["schemas"]["OcrTrainingRun"][];
|
||||
personNames?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
StreamingResponseBody: unknown;
|
||||
OcrJob: {
|
||||
/** Format: uuid */
|
||||
@@ -2442,6 +2533,30 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
triggerSenderTraining: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["TriggerSenderTrainingDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Accepted */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["OcrTrainingRun"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
triggerSegTraining: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3497,6 +3612,48 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getSenderTrainingHistory: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
personId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["TrainingHistoryResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getGlobalTrainingHistory: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["TrainingHistoryResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
exportTrainingData: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -122,6 +122,23 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
{#snippet ocrIcon()}
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('ocr') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<svelte:document onkeydown={handleKeydown} />
|
||||
|
||||
<!--
|
||||
@@ -188,6 +205,17 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
icon={systemIcon}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if canRunMaintenance}
|
||||
<EntityNavSection
|
||||
variant="sidebar"
|
||||
href="/admin/ocr"
|
||||
label={m.admin_tab_ocr()}
|
||||
isActive={isActive('ocr')}
|
||||
onTabletTrigger={openFlyout}
|
||||
icon={ocrIcon}
|
||||
/>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
{#if flyoutOpen}
|
||||
@@ -261,5 +289,16 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
icon={systemIcon}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if canRunMaintenance}
|
||||
<EntityNavSection
|
||||
variant="flyout"
|
||||
href="/admin/ocr"
|
||||
label={m.admin_tab_ocr()}
|
||||
isActive={isActive('ocr')}
|
||||
onFlyoutClick={closeFlyout}
|
||||
icon={ocrIcon}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -79,3 +79,16 @@ describe('EntityNav — flyout', () => {
|
||||
await expect.element(page.getByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EntityNav — OCR entry', () => {
|
||||
it('renders OCR link when canRunMaintenance is true', async () => {
|
||||
render(EntityNav, props);
|
||||
await expect.element(page.getByRole('link', { name: /OCR/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render OCR link when canRunMaintenance is false', async () => {
|
||||
render(EntityNav, { ...props, canRunMaintenance: false });
|
||||
const links = document.querySelectorAll('a[href="/admin/ocr"]');
|
||||
expect(links.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
16
frontend/src/routes/admin/ocr/+page.server.ts
Normal file
16
frontend/src/routes/admin/ocr/+page.server.ts
Normal file
@@ -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');
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
}
|
||||
|
||||
return { trainingInfo: result.data! };
|
||||
};
|
||||
57
frontend/src/routes/admin/ocr/+page.svelte
Normal file
57
frontend/src/routes/admin/ocr/+page.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import OcrHealthBar from './OcrHealthBar.svelte';
|
||||
import OcrStatCards from './OcrStatCards.svelte';
|
||||
import OcrModelsTable from './OcrModelsTable.svelte';
|
||||
import OcrTrainingCard from '$lib/components/OcrTrainingCard.svelte';
|
||||
import SegmentationTrainingCard from '$lib/components/SegmentationTrainingCard.svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const { trainingInfo } = $derived(data);
|
||||
</script>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<div class="mx-auto flex max-w-5xl flex-col gap-6">
|
||||
<!-- Page title + health bar -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="font-sans text-lg font-bold tracking-widest text-brand-navy uppercase">
|
||||
{m.admin_tab_ocr()}
|
||||
</h1>
|
||||
<OcrHealthBar ocrServiceAvailable={trainingInfo.ocrServiceAvailable ?? false} />
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<OcrStatCards
|
||||
availableBlocks={trainingInfo.availableBlocks ?? 0}
|
||||
totalOcrBlocks={trainingInfo.totalOcrBlocks ?? 0}
|
||||
availableDocuments={trainingInfo.availableDocuments ?? 0}
|
||||
availableSegBlocks={trainingInfo.availableSegBlocks ?? 0}
|
||||
/>
|
||||
|
||||
<!-- Training -->
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<OcrTrainingCard trainingInfo={trainingInfo} />
|
||||
<SegmentationTrainingCard trainingInfo={trainingInfo} />
|
||||
</div>
|
||||
|
||||
<!-- Sender models -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.ocr_sender_models_heading()}
|
||||
</h2>
|
||||
<a
|
||||
href="/admin/ocr/global"
|
||||
class="text-xs font-medium text-brand-navy/60 transition-colors hover:text-brand-navy focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
{m.ocr_global_history_link()}
|
||||
</a>
|
||||
</div>
|
||||
<OcrModelsTable
|
||||
senderModels={trainingInfo.senderModels ?? []}
|
||||
personNames={trainingInfo.personNames ?? {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
17
frontend/src/routes/admin/ocr/OcrHealthBar.svelte
Normal file
17
frontend/src/routes/admin/ocr/OcrHealthBar.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
let { ocrServiceAvailable }: { ocrServiceAvailable: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
role="img"
|
||||
aria-label={ocrServiceAvailable ? m.ocr_status_online() : m.ocr_status_offline()}
|
||||
class="inline-block h-2.5 w-2.5 rounded-full {ocrServiceAvailable
|
||||
? 'bg-green-500'
|
||||
: 'bg-red-500'}"
|
||||
></span>
|
||||
<span class="text-sm font-medium {ocrServiceAvailable ? 'text-green-700' : 'text-red-700'}">
|
||||
{ocrServiceAvailable ? m.ocr_status_online() : m.ocr_status_offline()}
|
||||
</span>
|
||||
</div>
|
||||
19
frontend/src/routes/admin/ocr/OcrHealthBar.svelte.spec.ts
Normal file
19
frontend/src/routes/admin/ocr/OcrHealthBar.svelte.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
78
frontend/src/routes/admin/ocr/OcrModelsTable.svelte
Normal file
78
frontend/src/routes/admin/ocr/OcrModelsTable.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
type SenderModel = components['schemas']['SenderModel'];
|
||||
|
||||
let {
|
||||
senderModels,
|
||||
personNames
|
||||
}: {
|
||||
senderModels: SenderModel[];
|
||||
personNames: Record<string, string>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
class="border-brand-sand border-b pb-3 text-left text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>{m.ocr_table_person()}</th
|
||||
>
|
||||
<th
|
||||
class="border-brand-sand border-b pb-3 text-left text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>{m.ocr_table_cer()}</th
|
||||
>
|
||||
<th
|
||||
class="border-brand-sand border-b pb-3 text-left text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>{m.ocr_table_accuracy()}</th
|
||||
>
|
||||
<th
|
||||
class="border-brand-sand border-b pb-3 text-left text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>{m.ocr_table_lines()}</th
|
||||
>
|
||||
<th
|
||||
class="border-brand-sand border-b pb-3 text-left text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>{m.ocr_table_actions()}</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each senderModels as model (model.id)}
|
||||
<tr>
|
||||
<td class="border-brand-sand/50 border-b">
|
||||
<a
|
||||
href="/admin/ocr/{model.personId}"
|
||||
class="inline-block py-3 text-brand-navy hover:underline focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
{personNames[model.personId] ?? model.personId}
|
||||
</a>
|
||||
</td>
|
||||
<td class="border-brand-sand/50 border-b py-3">
|
||||
{model.cer != null ? (model.cer * 100).toFixed(1) + '%' : '—'}
|
||||
</td>
|
||||
<td class="border-brand-sand/50 border-b py-3">
|
||||
{model.accuracy != null ? (model.accuracy * 100).toFixed(1) + '%' : '—'}
|
||||
</td>
|
||||
<td class="border-brand-sand/50 border-b py-3">
|
||||
{model.correctedLinesAtTraining}
|
||||
</td>
|
||||
<td class="border-brand-sand/50 border-b">
|
||||
<a
|
||||
href="/admin/ocr/{model.personId}"
|
||||
class="inline-block py-3 font-medium text-brand-navy hover:underline focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>{m.ocr_table_details()}</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="5" class="py-6 text-center text-sm text-gray-400">
|
||||
{m.ocr_no_models()}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
45
frontend/src/routes/admin/ocr/OcrModelsTable.svelte.spec.ts
Normal file
45
frontend/src/routes/admin/ocr/OcrModelsTable.svelte.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
44
frontend/src/routes/admin/ocr/OcrStatCards.svelte
Normal file
44
frontend/src/routes/admin/ocr/OcrStatCards.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
availableBlocks?: number;
|
||||
totalOcrBlocks?: number;
|
||||
availableDocuments?: number;
|
||||
availableSegBlocks?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
availableBlocks = 0,
|
||||
totalOcrBlocks = 0,
|
||||
availableDocuments = 0,
|
||||
availableSegBlocks = 0
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
|
||||
<div class="text-3xl font-bold text-brand-navy">{availableBlocks}</div>
|
||||
<div class="mt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.ocr_stat_training_blocks()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
|
||||
<div class="text-3xl font-bold text-brand-navy">{totalOcrBlocks}</div>
|
||||
<div class="mt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.ocr_stat_total_blocks()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
|
||||
<div class="text-3xl font-bold text-brand-navy">{availableDocuments}</div>
|
||||
<div class="mt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.ocr_stat_documents()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
|
||||
<div class="text-3xl font-bold text-brand-navy">{availableSegBlocks}</div>
|
||||
<div class="mt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.ocr_stat_seg_blocks()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
35
frontend/src/routes/admin/ocr/OcrStatCards.svelte.spec.ts
Normal file
35
frontend/src/routes/admin/ocr/OcrStatCards.svelte.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
18
frontend/src/routes/admin/ocr/[personId]/+page.server.ts
Normal file
18
frontend/src/routes/admin/ocr/[personId]/+page.server.ts
Normal file
@@ -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 };
|
||||
};
|
||||
39
frontend/src/routes/admin/ocr/[personId]/+page.svelte
Normal file
39
frontend/src/routes/admin/ocr/[personId]/+page.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import TrainingHistory from '$lib/components/TrainingHistory.svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const personName = $derived(data.history.personNames?.[data.personId] ?? 'Unknown');
|
||||
</script>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<div class="mx-auto flex max-w-4xl flex-col gap-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="/admin/ocr"
|
||||
class="group inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" /></svg
|
||||
>
|
||||
{m.admin_tab_ocr()}
|
||||
</a>
|
||||
<h1 class="font-sans text-lg font-bold tracking-widest text-brand-navy uppercase">
|
||||
{personName}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
|
||||
<TrainingHistory
|
||||
runs={data.history.runs ?? []}
|
||||
personNames={data.history.personNames ?? {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
33
frontend/src/routes/admin/ocr/[personId]/page.server.spec.ts
Normal file
33
frontend/src/routes/admin/ocr/[personId]/page.server.spec.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
36
frontend/src/routes/admin/ocr/[personId]/page.svelte.spec.ts
Normal file
36
frontend/src/routes/admin/ocr/[personId]/page.svelte.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
16
frontend/src/routes/admin/ocr/global/+page.server.ts
Normal file
16
frontend/src/routes/admin/ocr/global/+page.server.ts
Normal file
@@ -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! };
|
||||
};
|
||||
38
frontend/src/routes/admin/ocr/global/+page.svelte
Normal file
38
frontend/src/routes/admin/ocr/global/+page.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import TrainingHistory from '$lib/components/TrainingHistory.svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<div class="mx-auto flex max-w-4xl flex-col gap-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="/admin/ocr"
|
||||
class="group inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" /></svg
|
||||
>
|
||||
{m.admin_tab_ocr()}
|
||||
</a>
|
||||
<h1 class="font-sans text-lg font-bold tracking-widest text-brand-navy uppercase">
|
||||
{m.ocr_global_history_heading()}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
|
||||
<TrainingHistory
|
||||
runs={data.history.runs ?? []}
|
||||
personNames={data.history.personNames ?? {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
27
frontend/src/routes/admin/ocr/global/page.server.spec.ts
Normal file
27
frontend/src/routes/admin/ocr/global/page.server.spec.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
28
frontend/src/routes/admin/ocr/page.server.spec.ts
Normal file
28
frontend/src/routes/admin/ocr/page.server.spec.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import OcrTrainingCard from '$lib/components/OcrTrainingCard.svelte';
|
||||
import SegmentationTrainingCard from '$lib/components/SegmentationTrainingCard.svelte';
|
||||
import type { components } from '$lib/generated/api.js';
|
||||
|
||||
type TrainingInfo = components['schemas']['TrainingInfoResponse'];
|
||||
|
||||
let trainingInfo: TrainingInfo | null = $state(null);
|
||||
|
||||
let backfillResult: number | null = $state(null);
|
||||
let backfillLoading = $state(false);
|
||||
@@ -58,16 +51,8 @@ async function triggerImport() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTrainingInfo() {
|
||||
const res = await fetch('/api/ocr/training-info');
|
||||
if (res.ok) {
|
||||
trainingInfo = await res.json();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
fetchImportStatus();
|
||||
fetchTrainingInfo();
|
||||
});
|
||||
|
||||
onDestroy(() => stopPolling());
|
||||
@@ -103,12 +88,6 @@ async function backfillFileHashes() {
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<div class="mx-auto max-w-2xl space-y-5">
|
||||
<!-- OCR Recognition Training -->
|
||||
<OcrTrainingCard trainingInfo={trainingInfo} />
|
||||
|
||||
<!-- OCR Segmentation Training -->
|
||||
<SegmentationTrainingCard trainingInfo={trainingInfo} />
|
||||
|
||||
<!-- Backfill versions -->
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-1 font-sans text-sm font-bold text-ink">{m.admin_system_backfill_heading()}</h2>
|
||||
|
||||
@@ -78,8 +78,6 @@ describe('Admin system page — mass import card', () => {
|
||||
startedAt: null
|
||||
})
|
||||
})
|
||||
// training info fetch → empty
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
// trigger POST → returns RUNNING immediately
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
||||
Reference in New Issue
Block a user