refactor(ocr): make single-document OCR async, fix circular dependency
Some checks failed
CI / Unit & Component Tests (push) Failing after 1s
CI / Backend Unit Tests (push) Failing after 1s
CI / Unit & Component Tests (pull_request) Failing after 1s
CI / Backend Unit Tests (pull_request) Failing after 1s

OcrService → OcrAsyncRunner was circular. Fixed by moving all OCR
processing logic (processDocument, clearExistingBlocks, createBlocks)
into OcrAsyncRunner. OcrService is now a thin entry point that
validates, creates the job, and dispatches to OcrAsyncRunner.

Architecture:
- OcrService: validates document, checks health, creates OcrJob, delegates
- OcrAsyncRunner: @Async processDocument + runSingleDocument + runBatch
- OcrBatchService: creates job + job documents, delegates to OcrAsyncRunner
- No circular dependencies

Single-document OCR is now async (returns jobId immediately).
Frontend polls GET /api/ocr/jobs/{jobId} every 3s until DONE/FAILED.

816 backend tests pass, 687 frontend tests pass.

Refs #226

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-12 22:55:52 +02:00
parent 741979304c
commit dd175d09e2
7 changed files with 388 additions and 359 deletions

View File

@@ -6,7 +6,6 @@ import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte';
import TranscriptionReadView from '$lib/components/TranscriptionReadView.svelte';
import TranscriptionPanelHeader from '$lib/components/TranscriptionPanelHeader.svelte';
import OcrProgress from '$lib/components/OcrProgress.svelte';
import type { TranscriptionBlockData } from '$lib/types';
let { data } = $props();
@@ -58,7 +57,6 @@ let activeAnnotationId = $state<string | null>(null);
let highlightBlockId = $state<string | null>(null);
let flashAnnotationId = $state<string | null>(null);
let pdfStripExpanded = $state(false);
let ocrJobId = $state<string | null>(null);
const prefersReducedMotion = $derived(
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -129,7 +127,11 @@ async function reviewToggle(blockId: string) {
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
}
let ocrRunning = $state(false);
let ocrPollTimer = $state<ReturnType<typeof setInterval> | null>(null);
async function triggerOcr(scriptType: string) {
ocrRunning = true;
try {
const res = await fetch(`/api/documents/${doc.id}/ocr`, {
method: 'POST',
@@ -138,18 +140,35 @@ async function triggerOcr(scriptType: string) {
});
if (res.ok) {
const data = await res.json();
ocrJobId = data.jobId;
pollOcrJob(data.jobId);
} else {
ocrRunning = false;
}
} catch (e) {
console.error('Failed to trigger OCR:', e);
ocrRunning = false;
}
}
async function handleOcrDone() {
ocrJobId = null;
await loadTranscriptionBlocks();
annotationReloadKey++;
panelMode = transcriptionBlocks.length > 0 ? 'read' : 'edit';
function pollOcrJob(jobId: string) {
if (ocrPollTimer) clearInterval(ocrPollTimer);
ocrPollTimer = setInterval(async () => {
try {
const res = await fetch(`/api/ocr/jobs/${jobId}`);
if (!res.ok) return;
const job = await res.json();
if (job.status === 'DONE' || job.status === 'FAILED') {
if (ocrPollTimer) clearInterval(ocrPollTimer);
ocrPollTimer = null;
ocrRunning = false;
await loadTranscriptionBlocks();
annotationReloadKey++;
panelMode = transcriptionBlocks.length > 0 ? 'read' : 'edit';
}
} catch {
// polling is best-effort
}
}, 3000);
}
async function createBlockFromDraw(rect: {
@@ -232,28 +251,12 @@ function handleParagraphClick(annotationId: string) {
);
}
async function checkOcrStatus() {
if (!doc?.id) return;
try {
const res = await fetch(`/api/documents/${doc.id}/ocr-status`);
if (res.ok) {
const status = await res.json();
if (status.status === 'PENDING' || status.status === 'RUNNING') {
ocrJobId = status.jobId;
}
}
} catch {
// OCR status check is best-effort
}
}
// Load blocks and check OCR status when transcribe mode is entered
// Load blocks when transcribe mode is entered and set default panel mode
$effect(() => {
if (transcribeMode) {
loadTranscriptionBlocks().then(() => {
panelMode = transcriptionBlocks.length > 0 ? 'read' : 'edit';
});
checkOcrStatus();
}
});
@@ -277,7 +280,10 @@ onMount(() => {
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
if (ocrPollTimer) clearInterval(ocrPollTimer);
};
});
</script>
@@ -353,9 +359,30 @@ onMount(() => {
onClose={() => (transcribeMode = false)}
/>
<div class="flex-1 overflow-y-auto">
{#if ocrJobId}
<div class="p-4">
<OcrProgress jobId={ocrJobId} onDone={handleOcrDone} />
{#if ocrRunning}
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
<svg
class="mb-4 h-8 w-8 animate-spin text-brand-mint"
viewBox="0 0 24 24"
fill="none"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
<p class="text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.ocr_progress_heading()}
</p>
</div>
{:else if panelMode === 'read'}
<TranscriptionReadView