feat(admin): OCR admin pages — overview & model detail #265

Merged
marcel merged 53 commits from feat/issue-264-ocr-admin-pages into main 2026-04-18 12:38:42 +02:00
38 changed files with 1343 additions and 89 deletions

View File

@@ -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 {

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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 */

View File

@@ -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);
}

View File

@@ -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() {

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>
<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 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>
<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 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"

View File

@@ -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':

View File

@@ -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;

View File

@@ -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}

View File

@@ -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);
});
});

View 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! };
};

View 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>

View 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>

View 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');
});
});

View 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>

View 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();
});
});

View 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>

View 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();
});
});

View 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 };
};

View 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>

View 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 });
});
});

View 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');
});
});

View 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! };
};

View 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>

View 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 });
});
});

View 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 });
});
});

View File

@@ -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>

View File

@@ -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,