refactor(ocr): make single-document OCR async, fix circular dependency
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user