feat(training): add document-level training enrollment

- V29 migration: document_training_labels join table
- TrainingLabel enum: KURRENT_RECOGNITION, KURRENT_SEGMENTATION
- Document.trainingLabels @ElementCollection
- DocumentService.addTrainingLabel / removeTrainingLabel
- PATCH /api/documents/{id}/training-labels (WRITE_ALL)
- Auto-enroll on Kurrent OCR trigger (OcrService.startOcr)
- TranscriptionEditView: enrollment chips in panel footer
- JPQL queries updated to use MEMBER OF trainingLabels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-13 14:30:51 +02:00
parent 73229077be
commit fdf1eb92ad
12 changed files with 614 additions and 11 deletions

View File

@@ -14,6 +14,7 @@ import org.raddatz.familienarchiv.service.FileService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.springframework.http.MediaType;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.context.annotation.Import;
import org.springframework.security.test.context.support.WithMockUser;
@@ -31,8 +32,10 @@ import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.eq;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -485,6 +488,58 @@ class DocumentControllerTest {
.andExpect(jsonPath("$[0].editorName").value("Emma Müller"));
}
// ─── PATCH /api/documents/{id}/training-labels ───────────────────────────
@Test
void patchTrainingLabels_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void patchTrainingLabels_returns403_whenMissingWritePermission() throws Exception {
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchTrainingLabels_returns204_whenAddingLabel() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(patch("/api/documents/" + id + "/training-labels")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
.andExpect(status().isNoContent());
verify(documentService).addTrainingLabel(eq(id), any());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchTrainingLabels_returns204_whenRemovingLabel() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(patch("/api/documents/" + id + "/training-labels")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"label\":\"KURRENT_SEGMENTATION\",\"enrolled\":false}"))
.andExpect(status().isNoContent());
verify(documentService).removeTrainingLabel(eq(id), any());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchTrainingLabels_returns400_whenUnknownLabel() throws Exception {
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"label\":\"UNKNOWN_GARBAGE\",\"enrolled\":true}"))
.andExpect(status().isBadRequest());
}
// ─── GET /api/documents/{id}/versions/{versionId} ────────────────────────
@Test

View File

@@ -0,0 +1,124 @@
package org.raddatz.familienarchiv.repository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.model.*;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class TrainingBlockQueryTest {
@Autowired TranscriptionBlockRepository blockRepository;
@Autowired DocumentRepository documentRepository;
@Autowired AnnotationRepository annotationRepository;
private UUID kurrentDocId;
private UUID typewriterDocId;
private UUID kurrentAnnotationId;
private UUID typewriterAnnotationId;
@BeforeEach
void setUp() {
Document kurrentDoc = documentRepository.save(Document.builder()
.title("Kurrent Brief")
.originalFilename("kurrent.pdf")
.status(DocumentStatus.UPLOADED)
.trainingLabels(new java.util.HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
.build());
kurrentDocId = kurrentDoc.getId();
Document typewriterDoc = documentRepository.save(Document.builder()
.title("Getippter Brief")
.originalFilename("typed.pdf")
.status(DocumentStatus.UPLOADED)
.build());
typewriterDocId = typewriterDoc.getId();
kurrentAnnotationId = annotationRepository.save(annotation(kurrentDocId)).getId();
typewriterAnnotationId = annotationRepository.save(annotation(typewriterDocId)).getId();
}
@Test
void findEligibleKurrentBlocks_includesManualBlock() {
blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.MANUAL, false));
List<TranscriptionBlock> result = blockRepository.findEligibleKurrentBlocks();
assertThat(result).hasSize(1);
assertThat(result.get(0).getSource()).isEqualTo(BlockSource.MANUAL);
}
@Test
void findEligibleKurrentBlocks_includesReviewedOcrBlock() {
blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.OCR, true));
List<TranscriptionBlock> result = blockRepository.findEligibleKurrentBlocks();
assertThat(result).hasSize(1);
assertThat(result.get(0).isReviewed()).isTrue();
}
@Test
void findEligibleKurrentBlocks_excludesUnreviewedOcrBlock() {
blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.OCR, false));
List<TranscriptionBlock> result = blockRepository.findEligibleKurrentBlocks();
assertThat(result).isEmpty();
}
@Test
void findEligibleKurrentBlocks_excludesNonEnrolledDocument() {
blockRepository.save(block(typewriterDocId, typewriterAnnotationId, BlockSource.MANUAL, false));
List<TranscriptionBlock> result = blockRepository.findEligibleKurrentBlocks();
assertThat(result).isEmpty();
}
@Test
void findEligibleKurrentBlocks_returnsAllEligibleAcrossBothSources() {
blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.MANUAL, false));
blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.OCR, true));
blockRepository.save(block(kurrentDocId, kurrentAnnotationId, BlockSource.OCR, false)); // excluded
List<TranscriptionBlock> result = blockRepository.findEligibleKurrentBlocks();
assertThat(result).hasSize(2);
}
// ─── helpers ─────────────────────────────────────────────────────────────
private DocumentAnnotation annotation(UUID docId) {
return DocumentAnnotation.builder()
.documentId(docId)
.pageNumber(1)
.x(0.1).y(0.2).width(0.3).height(0.4)
.color("#00C7B1")
.build();
}
private TranscriptionBlock block(UUID docId, UUID annotId, BlockSource source, boolean reviewed) {
return TranscriptionBlock.builder()
.annotationId(annotId)
.documentId(docId)
.text("Liebe Tante")
.sortOrder(0)
.source(source)
.reviewed(reviewed)
.build();
}
}