refactor: move ocr domain components to lib/ocr/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-05 14:23:55 +02:00
parent 051d2f246e
commit 920742ba1c
11 changed files with 5 additions and 5 deletions

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
interface Props {
jobId: string;
onDone: () => void;
}
let { jobId, onDone }: Props = $props();
let status: 'running' | 'done' | 'error' = $state('running');
let processed: number = $state(0);
let total: number = $state(0);
let currentPage: number = $state(0);
let totalPages: number = $state(0);
let retryCount: number = $state(0);
let progressPercent = $derived(total > 0 ? Math.round((processed / total) * 100) : 0);
$effect(() => {
void retryCount; // track dependency to re-create EventSource on retry
const source = new EventSource(`/api/ocr/jobs/${jobId}/progress`);
source.addEventListener('document', (e) => {
const data = JSON.parse(e.data);
processed = data.processed;
total = data.total;
});
source.addEventListener('page', (e) => {
const data = JSON.parse(e.data);
currentPage = data.page;
totalPages = data.totalPages;
});
source.addEventListener('done', () => {
status = 'done';
source.close();
onDone();
});
source.addEventListener('error', () => {
status = 'error';
source.close();
});
source.onerror = () => {
status = 'error';
source.close();
};
return () => {
source.close();
};
});
</script>
{#if status === 'running'}
<div class="border-brand-sand rounded-sm border bg-white p-4">
<h3 class="mb-3 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.ocr_progress_heading()}
</h3>
<div class="bg-brand-sand h-2 w-full overflow-hidden rounded-full">
<div
class="h-full bg-brand-mint transition-all duration-300"
style="width: {progressPercent}%"
role="progressbar"
aria-label={m.ocr_progress_heading()}
aria-valuenow={progressPercent}
aria-valuemin={0}
aria-valuemax={100}
></div>
</div>
<p class="mt-2 text-right text-sm text-gray-500">
<span aria-live="polite" aria-atomic="true">
{m.ocr_progress_page({ current: String(currentPage), total: String(totalPages) })}
</span>
</p>
</div>
{:else if status === 'error'}
<div class="border-brand-sand rounded-sm border border-l-4 border-l-red-500 bg-white p-4">
<h3 class="mb-2 text-sm font-semibold text-red-700">
{m.ocr_error_heading()}
</h3>
<button
type="button"
onclick={() => { retryCount++; status = 'running'; }}
class="text-sm font-medium text-brand-navy transition-colors hover:text-brand-navy/80"
>
{m.ocr_error_retry()}
</button>
</div>
{/if}

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import TrainingHistory from './TrainingHistory.svelte';
import { m } from '$lib/paraglide/messages.js';
import type { TrainingRun } from '$lib/types/training.js';
interface TrainingInfo {
availableBlocks?: number;
totalOcrBlocks?: number;
availableDocuments?: number;
ocrServiceAvailable?: boolean;
lastRun?: TrainingRun | null;
runs?: TrainingRun[];
personNames?: Record<string, string>;
}
interface Props {
trainingInfo: TrainingInfo | null;
}
let { trainingInfo }: Props = $props();
let training = $state(false);
let successMessage = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
const available = $derived(trainingInfo?.availableBlocks ?? 0);
const tooFewBlocks = $derived(available < 5);
const serviceDown = $derived(trainingInfo?.ocrServiceAvailable === false);
const disabled = $derived(training || tooFewBlocks || serviceDown);
async function startTraining() {
training = true;
successMessage = null;
errorMessage = null;
try {
const res = await fetch('/api/ocr/train', { method: 'POST' });
if (res.ok) {
successMessage = m.training_success();
setTimeout(() => {
successMessage = null;
}, 5000);
} else {
errorMessage = m.training_start_failed();
}
} catch {
errorMessage = m.training_start_failed();
} finally {
training = false;
}
}
</script>
<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.training_ocr_heading()}</h2>
<p class="mb-4 text-sm text-ink-2">{m.training_ocr_description()}</p>
<p class="mb-3 text-sm text-ink">
{m.training_ocr_blocks_ready({ blocks: available, docs: trainingInfo?.availableDocuments ?? 0 })}
<span class="text-ink-2"
>{m.training_ocr_blocks_total({ total: trainingInfo?.totalOcrBlocks ?? 0 })}</span
>
</p>
<button
onclick={startTraining}
disabled={disabled}
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-brand-navy disabled:cursor-not-allowed disabled:opacity-50"
>
{training ? '…' : m.training_start_btn()}
</button>
{#if tooFewBlocks}
<p class="mt-2 text-xs text-ink-3">
{m.training_too_few_blocks({ available })}
</p>
{:else if serviceDown}
<p class="mt-2 text-xs text-orange-600">{m.training_service_down()}</p>
{/if}
{#if successMessage}
<p class="mt-2 flex items-center gap-2 text-xs text-green-700" aria-live="polite">
{successMessage}
<button
type="button"
class="-my-2 ml-1 inline-flex h-11 w-11 items-center justify-center rounded-sm underline hover:no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
onclick={() => (successMessage = null)}
aria-label={m.comp_dismiss()}>×</button
>
</p>
{/if}
{#if errorMessage}
<p class="mt-2 text-xs text-red-600" aria-live="assertive">{errorMessage}</p>
{/if}
<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')}
personNames={trainingInfo?.personNames ?? {}}
/>
</div>

View File

@@ -0,0 +1,114 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import OcrTrainingCard from './OcrTrainingCard.svelte';
afterEach(cleanup);
afterEach(() => vi.restoreAllMocks());
const baseInfo = {
availableBlocks: 10,
totalOcrBlocks: 20,
availableDocuments: 3,
ocrServiceAvailable: true,
lastRun: null,
runs: []
};
describe('OcrTrainingCard — disabled states', () => {
it('disables button and shows hint when availableBlocks is 0', async () => {
render(OcrTrainingCard, { trainingInfo: { ...baseInfo, availableBlocks: 0 } });
const btn = page.getByRole('button', { name: /Training starten/i });
await expect.element(btn).toBeDisabled();
await expect
.element(page.getByText(/Mindestens 5 geprüfte Blöcke erforderlich/i))
.toBeInTheDocument();
});
it('disables button and shows hint when availableBlocks is less than 5', async () => {
render(OcrTrainingCard, { trainingInfo: { ...baseInfo, availableBlocks: 3 } });
const btn = page.getByRole('button', { name: /Training starten/i });
await expect.element(btn).toBeDisabled();
await expect.element(page.getByText(/Mindestens 5/i)).toBeInTheDocument();
});
it('disables button and shows service-down warning when ocrServiceAvailable is false', async () => {
render(OcrTrainingCard, { trainingInfo: { ...baseInfo, ocrServiceAvailable: false } });
const btn = page.getByRole('button', { name: /Training starten/i });
await expect.element(btn).toBeDisabled();
await expect.element(page.getByText(/OCR-Dienst ist nicht erreichbar/i)).toBeInTheDocument();
});
it('does not show service-down warning when blocks are insufficient', async () => {
// tooFewBlocks hint takes priority over serviceDown hint
render(OcrTrainingCard, {
trainingInfo: { ...baseInfo, availableBlocks: 2, ocrServiceAvailable: false }
});
await expect.element(page.getByText(/Mindestens 5/i)).toBeInTheDocument();
// serviceDown text should NOT appear because tooFewBlocks branch hides it
const serviceMsg = document.querySelector('.text-orange-600');
expect(serviceMsg).toBeNull();
});
});
describe('OcrTrainingCard — enabled state', () => {
it('enables button when availableBlocks >= 5 and service is up', async () => {
render(OcrTrainingCard, { trainingInfo: baseInfo });
const btn = page.getByRole('button', { name: /Training starten/i });
await expect.element(btn).not.toBeDisabled();
});
it('shows block count info text', async () => {
render(OcrTrainingCard, {
trainingInfo: { ...baseInfo, availableBlocks: 7, totalOcrBlocks: 15 }
});
await expect.element(page.getByText(/7/)).toBeInTheDocument();
await expect.element(page.getByText(/von 15 OCR-Blöcken/i)).toBeInTheDocument();
});
});
describe('OcrTrainingCard — success dismiss button', () => {
it('dismiss button has 44×44px touch target (h-11 w-11)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
render(OcrTrainingCard, { trainingInfo: baseInfo });
const btn = page.getByRole('button', { name: /Training starten/i });
await btn.click();
const dismissBtn = page.getByRole('button', { name: /Schließen/i });
await expect.element(dismissBtn).toBeInTheDocument();
const el = await dismissBtn.element();
expect(el.classList.contains('h-11')).toBe(true);
expect(el.classList.contains('w-11')).toBe(true);
});
});
describe('OcrTrainingCard — in-flight state', () => {
it('shows "…" while POST is in-flight', async () => {
let resolveFetch!: (v: unknown) => void;
const pendingFetch = new Promise((resolve) => {
resolveFetch = resolve;
});
vi.stubGlobal('fetch', vi.fn().mockReturnValue(pendingFetch));
render(OcrTrainingCard, { trainingInfo: baseInfo });
const btn = page.getByRole('button', { name: /Training starten/i });
await btn.click();
// While fetch is still pending the button label becomes "…"
await expect.element(page.getByRole('button', { name: '…' })).toBeInTheDocument();
// Cleanup: resolve the pending promise
resolveFetch({ ok: false });
});
});

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import ScriptTypeSelect from '$lib/document/ScriptTypeSelect.svelte';
interface Props {
blockCount: number;
storedScriptType: string;
onTrigger: (scriptType: string, useExistingAnnotations: boolean) => void;
}
let { blockCount, storedScriptType, onTrigger }: Props = $props();
import { untrack } from 'svelte';
let selectedScriptType: string = $state(
untrack(() => (storedScriptType && storedScriptType !== 'UNKNOWN' ? storedScriptType : ''))
);
function handleClick() {
if (!selectedScriptType) return;
onTrigger(selectedScriptType, true);
}
</script>
<div class="flex flex-col gap-3">
<ScriptTypeSelect bind:value={selectedScriptType} />
<button
type="button"
disabled={!selectedScriptType || blockCount === 0}
title={!selectedScriptType ? m.ocr_trigger_btn_disabled() : undefined}
onclick={handleClick}
class="min-h-[44px] w-full rounded-sm bg-brand-navy font-sans text-sm font-medium text-white transition-colors hover:bg-brand-navy/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{m.ocr_trigger_btn()}
</button>
{#if blockCount === 0}
<p class="text-xs text-ink-3">{m.ocr_trigger_no_annotations()}</p>
{/if}
</div>

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import TrainingHistory from './TrainingHistory.svelte';
import { m } from '$lib/paraglide/messages.js';
import type { TrainingRun } from '$lib/types/training.js';
interface TrainingInfo {
availableSegBlocks?: number;
ocrServiceAvailable?: boolean;
runs?: TrainingRun[];
}
interface Props {
trainingInfo: TrainingInfo | null;
}
let { trainingInfo }: Props = $props();
let training = $state(false);
let successMessage = $state<string | null>(null);
const available = $derived(trainingInfo?.availableSegBlocks ?? 0);
const tooFewBlocks = $derived(available < 5);
const serviceDown = $derived(trainingInfo?.ocrServiceAvailable === false);
const disabled = $derived(training || tooFewBlocks || serviceDown);
async function startTraining() {
training = true;
successMessage = null;
try {
const res = await fetch('/api/ocr/segtrain', { method: 'POST' });
if (res.ok) {
successMessage = m.training_success();
setTimeout(() => {
successMessage = null;
}, 5000);
}
} finally {
training = false;
}
}
</script>
<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.training_seg_heading()}</h2>
<p class="mb-4 text-sm text-ink-2">{m.training_seg_description()}</p>
<p class="mb-3 text-sm text-ink">
{m.training_seg_blocks_ready({ blocks: available })}
</p>
<button
onclick={startTraining}
disabled={disabled}
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-brand-navy disabled:cursor-not-allowed disabled:opacity-50"
>
{training ? '…' : m.training_start_btn()}
</button>
{#if tooFewBlocks}
<p class="mt-2 text-xs text-ink-3">
{m.training_seg_too_few_blocks({ available })}
</p>
{:else if serviceDown}
<p class="mt-2 text-xs text-orange-600">{m.training_service_down()}</p>
{/if}
{#if successMessage}
<p class="mt-2 text-xs text-green-700">{successMessage}</p>
{/if}
<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')}
showPersonColumns={false}
/>
</div>

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import type { TrainingRun } from '$lib/types/training.js';
interface Props {
runs: TrainingRun[];
personNames?: Record<string, string>;
showPersonColumns?: boolean;
}
let { runs, personNames, showPersonColumns = true }: Props = $props();
const COLLAPSED_COUNT = 3;
let expanded = $state(false);
const visibleRuns = $derived(expanded ? runs : runs.slice(0, COLLAPSED_COUNT));
const hasMore = $derived(runs.length > COLLAPSED_COUNT);
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
function formatDate(iso: string): string {
return dateFormatter.format(new Date(iso));
}
function formatCer(cer: number | undefined | null): string {
if (cer == null) return '—';
return (cer * 100).toFixed(1) + ' %';
}
</script>
<table class="w-full text-sm">
<thead>
<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>
</tr>
</thead>
<tbody id="training-history-rows">
{#if runs.length === 0}
<tr>
<td colspan={showPersonColumns ? 7 : 5} class="py-4 text-center text-sm text-ink-2">
{m.training_history_empty()}
</td>
</tr>
{:else}
{#each visibleRuns as run (run.id)}
<tr class="border-b border-line/50 last:border-0">
<td class="py-2 text-ink-2">{formatDate(run.createdAt)}</td>
<td class="py-2">
{#if run.status === 'QUEUED'}
<span
class="inline-flex items-center gap-1 rounded-sm bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700"
>
<span aria-hidden="true" class="h-1.5 w-1.5 rounded-full bg-amber-500"></span>
{m.training_status_queued()}
</span>
{:else if run.status === 'DONE'}
<span
class="inline-flex items-center gap-1 rounded-sm bg-green-100 px-1.5 py-0.5 text-xs font-medium text-green-700"
>
<svg
aria-hidden="true"
class="h-3 w-3 shrink-0"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
{m.training_status_done()}
</span>
{:else if run.status === 'FAILED'}
<span
class="inline-flex items-center gap-1 rounded-sm bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700"
>
<svg
aria-hidden="true"
class="h-3 w-3 shrink-0"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
{m.training_status_failed()}
</span>
{#if run.errorMessage}
<details class="mt-0.5">
<summary class="cursor-pointer text-xs text-red-700 underline">
{m.training_error_detail_label()}
</summary>
<p class="mt-1 text-xs text-red-600">{run.errorMessage}</p>
</details>
{/if}
{:else}
<span
class="inline-flex items-center gap-1 rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-700"
>
<span
aria-hidden="true"
class="h-1.5 w-1.5 rounded-full bg-yellow-500 motion-safe:animate-pulse"
></span>
{m.training_status_running()}
</span>
{/if}
{#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"
>{run.status === 'DONE' && run.cer != null ? formatCer(run.cer) : '—'}</td
>
</tr>
{/each}
{/if}
</tbody>
</table>
{#if hasMore}
<div class="mt-2 text-center">
<button
type="button"
aria-expanded={expanded}
aria-controls="training-history-rows"
class="rounded-sm py-2 text-xs font-medium text-ink-3 transition-colors hover:text-ink focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 focus-visible:outline-none"
onclick={() => (expanded = !expanded)}
>
{expanded ? m.comp_expandable_show_less() : m.comp_expandable_show_more()}
</button>
</div>
{/if}

View File

@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TrainingHistory from './TrainingHistory.svelte';
afterEach(cleanup);
function makeRun(i: number) {
return {
id: `run-${i}`,
status: 'DONE' as const,
blockCount: 10,
documentCount: 2,
modelName: 'german_kurrent',
createdAt: `2026-01-0${i + 1}T12:00:00Z`,
completedAt: `2026-01-0${i + 1}T12:05:00Z`
};
}
const fiveRuns = Array.from({ length: 5 }, (_, i) => makeRun(i));
const twoRuns = Array.from({ length: 2 }, (_, i) => makeRun(i));
describe('TrainingHistory — expand/collapse', () => {
it('shows only 3 runs initially when more than 3 exist', async () => {
render(TrainingHistory, { runs: fiveRuns });
const rows = page.getByRole('row');
// 1 header row + 3 data rows = 4 total
await expect.element(rows.nth(3)).toBeInTheDocument();
await expect.element(rows.nth(4)).not.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /Mehr anzeigen/i })).toBeInTheDocument();
});
it('shows all runs after clicking the expand button', async () => {
render(TrainingHistory, { runs: fiveRuns });
await page.getByRole('button', { name: /Mehr anzeigen/i }).click();
const rows = page.getByRole('row');
// 1 header row + 5 data rows = 6 total
await expect.element(rows.nth(5)).toBeInTheDocument();
});
it('hides the toggle button when 3 or fewer runs exist', async () => {
render(TrainingHistory, { runs: twoRuns });
await expect
.element(page.getByRole('button', { name: /Mehr anzeigen/i }))
.not.toBeInTheDocument();
});
});
describe('TrainingHistory — status badges', () => {
it('shows amber QUEUED badge for queued runs', async () => {
const run = { ...makeRun(0), status: 'QUEUED' as const };
render(TrainingHistory, { runs: [run] });
await expect.element(page.getByText(/Warteschlange/i)).toBeInTheDocument();
});
});
describe('TrainingHistory — type and person columns', () => {
it('shows "Basis" for runs without personId', async () => {
render(TrainingHistory, { runs: [makeRun(0)] });
await expect.element(page.getByText(/Basis/i)).toBeInTheDocument();
});
it('shows "Personalisiert" for runs with personId', async () => {
const run = { ...makeRun(0), personId: 'person-1' };
render(TrainingHistory, { runs: [run], personNames: { 'person-1': 'Karl Müller' } });
await expect.element(page.getByText(/Personalisiert/i)).toBeInTheDocument();
});
it('shows person name from personNames for sender runs', async () => {
const run = { ...makeRun(0), personId: 'person-1' };
render(TrainingHistory, { runs: [run], personNames: { 'person-1': 'Karl Müller' } });
await expect.element(page.getByText(/Personalisiert/i)).toBeInTheDocument();
// Name appears in both the mobile inline span and the desktop table cell
await expect.element(page.getByText('Karl Müller').first()).toBeInTheDocument();
});
});