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

@@ -20,6 +20,9 @@ type Props = {
onDeleteBlock: (blockId: string) => Promise<void>;
onReviewToggle: (blockId: string) => Promise<void>;
onTriggerOcr?: (scriptType: string) => void;
canWrite?: boolean;
trainingLabels?: string[];
onToggleTrainingLabel?: (label: string, enrolled: boolean) => Promise<void>;
};
let {
@@ -34,10 +37,14 @@ let {
onSaveBlock,
onDeleteBlock,
onReviewToggle,
onTriggerOcr
onTriggerOcr,
canWrite = false,
trainingLabels = [],
onToggleTrainingLabel
}: Props = $props();
let activeBlockId: string | null = $state(null);
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
$effect(() => {
@@ -188,7 +195,7 @@ let dropTargetIdx: number | null = $state(null);
let dragOffsetY: number = $state(0);
let dragStartY = 0;
let capturedEl: HTMLElement | null = null;
let listEl: HTMLElement | null = null;
let listEl: HTMLElement | null = $state(null);
function handleGripDown(e: PointerEvent, blockId: string) {
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
@@ -240,6 +247,23 @@ function handlePointerUp() {
capturedEl = null;
}
async function handleLabelToggle(label: string) {
if (!onToggleTrainingLabel) return;
const enrolled = !localLabels.includes(label);
// Optimistic update
if (enrolled) {
localLabels = [...localLabels, label];
} else {
localLabels = localLabels.filter((l) => l !== label);
}
try {
await onToggleTrainingLabel(label, enrolled);
} catch {
// Revert on failure
localLabels = [...trainingLabels];
}
}
function flushViaBeacon() {
for (const [blockId, text] of pendingTexts) {
clearDebounce(blockId);
@@ -390,4 +414,23 @@ $effect(() => {
{/if}
</div>
{/if}
{#if canWrite}
<div class="border-t border-line px-4 py-3">
<p class="mb-2 font-sans text-xs font-medium text-ink-2">Für Training vormerken</p>
<div class="flex flex-wrap gap-2">
{#each [{ label: 'KURRENT_RECOGNITION', display: 'Kurrent-Erkennung' }, { label: 'KURRENT_SEGMENTATION', display: 'Segmentierung' }] as chip (chip.label)}
<button
type="button"
onclick={() => handleLabelToggle(chip.label)}
class="rounded-full border px-3 py-1 font-sans text-xs font-medium transition-colors {localLabels.includes(chip.label)
? 'border-brand-mint bg-brand-mint text-brand-navy'
: 'border-line bg-surface text-ink-3 hover:border-brand-mint hover:text-brand-navy'}"
>
{chip.display}
</button>
{/each}
</div>
</div>
{/if}
</div>