Table of contents
Plan: Generalized Job System with OCR + Translation Services
Goals
- Generalize the job system so the backend can post
tts,ocr, andtranslationjobs that worker services pick up, process, and produce output for. - OCR: create a new OCR'd PDF saved next to the original on disk, populate
documents.content(extracted text) anddocuments.ocr_pdf_storage_key. - Translation: add columns to
documentsfor the translated text and the source/target languages.
Decisions (confirmed)
- OCR-extracted text is stored in
documents.content(replaces the stubbed "Extract text" button). - Full migration: replace
tts_jobswith a genericjobstable. The app is not in production; migrations are rewritten in place, no backwards compatibility. - Translation engine: local
facebook/nllb-200-distilled-600Mvia transformers/torch (no API keys), FLORES-200 codes (already used by the app). - OCR fast path: extract text with pypdf; only run Tesseract OCR (and store the new PDF) when no text layer exists or "Force OCR" is checked.
Architecture
Extends the existing pattern (Postgres-backed queue, Go worker goroutine in the backend, stateless Python HTTP services):
Frontend ── POST job ──> Go backend ── INSERT ──> jobs table (generic: tts | ocr | translation)
│ worker goroutines (one per type, SKIP LOCKED claim, 2s poll)
├──> tts:8000 (existing, SSE)
├──> ocr:8000 (new, SSE result w/ PDF+text)
└──> translator:8000 (new, SSE progress + result)
└── on completion, publish outputs to documents table / /data disk
1. Database (rewrite migrations in place — dev DBs must be reset)
Rewrite backend/db/migrations/009_create_tts_jobs.*.sql → 009_create_jobs_table:
CREATE TABLE jobs (
job_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
document_id UUID REFERENCES documents(document_id) ON DELETE CASCADE,
job_type TEXT NOT NULL CHECK (job_type IN ('tts','ocr','translation')),
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued', -- queued | running | completed | failed
params JSONB NOT NULL DEFAULT '{}', -- type-specific input
result JSONB, -- type-specific output metadata
progress DOUBLE PRECISION NOT NULL DEFAULT 0, -- 0..1, updated from SSE progress
error TEXT,
created_at/updated_at/started_at/completed_at TIMESTAMPTZ ...,
UNIQUE (user_id, job_type, name)
);
CREATE INDEX jobs_status_type_created_at_idx ON jobs (status, job_type, created_at);
CREATE TABLE tts_job_segments (...) -- unchanged, but FK → jobs(job_id) ON DELETE CASCADE
- Blank out migration
011(renamed011_add_document_id_to_jobs, empty like the existing004— document_id is now in 009). - Name convention per document job:
project-<docID>— no collision across types thanks tojob_typein the unique constraint.
New 013_add_translation_to_documents:
ALTER TABLE documents
ADD COLUMN translated_content TEXT,
ADD COLUMN translation_source_language VARCHAR(50), -- FLORES-200 codes (dan_Latn …)
ADD COLUMN translation_target_language VARCHAR(50);
OCR text goes into existing documents.content; the OCR'd PDF key into existing ocr_pdf_storage_key.
2. Backend (Go)
Models — new internal/models/job.go: Job struct with JobType constants, Status constants reused from tts_job.go, typed param accessors (TTS: text/language/instruct/voice/speed; OCR: language/force; Translation: source/target lang).
Store — internal/storage/postgres/job_store.go replaces tts_job_store.go:
CreateJob,GetJobByName(userID, type, name),GetJobByID,ListJobs(userID, type)ClaimNextQueuedJob(jobType)— sameFOR UPDATE SKIP LOCKEDtransactionRequeueStaleRunningJobs(30min)— all typesMarkJobCompleted/Failed,UpdateJobProgress,SetJobResult- Segment functions (
InsertJobSegment,GetJobSegments) andReplaceSpeechFromJobSegmentsmove over unchanged.
Worker — new internal/jobs/ package; Run(ctx) starts one polling goroutine per job type (a slow OCR never blocks TTS):
jobs/tts.go— port ofinternal/tts/worker.goprocessJob (params now from JSONB; segments/SSE/sound_snippets logic identical). Move the generic SSE parser frominternal/tts/sse.gointojobs/sse.go(keep TTS audio decode in tts.go).jobs/ocr.go— new processor:- Load document; fail if
original_pdf_storage_keyis NULL. - Read
/data/{user_id}/{key}.pdf, multipart POST to OCR service (fields:languagetesseract code,force), parse SSE. - On result event: if
ocr_performed, save PDF bytes via new storage key (files package pattern frompdf.go), setocr_pdf_storage_key(replacing old file if present); always setdocuments.content = text. - Mark completed with result
{ocr_performed, pages}.
- Load document; fail if
jobs/translation.go— new processor: readdocuments.content(fail if empty), POST{text, src_lang, tgt_lang}to translator service, updateprogressfrom SSE progress events, on result settranslated_content+ both language columns, mark completed.- All processors use
context.WithoutCanceland long/no HTTP client timeouts (like TTS today). Service URLs from env:OCR_SERVICE_URL(defaulthttp://ocr:8000),TRANSLATION_SERVICE_URL(defaulthttp://translator:8000). main.go: startjobs.NewWorker(store).Run(ctx)replacing the TTS worker startup.
Handlers:
- Project-scoped TTS job endpoints:
GET /project/text-to-speech/:projectIDreturns job status,GET /project/text-to-speech/:projectID/segmentsreturns the published speech snippets (wasGetProjectSpeech), plus existing POST/PUT/DELETE. The generic free-text/text-to-speech/jobs*endpoints were removed (only the AudioTest dev page used them; it was removed too), leaving a consistent project-scoped job API across all three types. - New
ocr.gohandler:POST /user/project/ocr/:projectID{force?, language?}(language defaults fromdocument.languagevia new FLORES→tesseract map:eng_Latn→eng,dan_Latn→dan, …) andGET /user/project/ocr/:projectID(job status). Dedup/requeue semantics copied fromGenProjectTextToSpeech. - New
translation.gohandler:POST /user/project/translation/:projectID{target_language, source_language?}(source defaultsdocument.language),GET,PUT(force re-translate),DELETE(clear translated columns). - New
GET /user/project/ocr-pdf/:documentID— serves the OCR'd PDF (404 when none), mirroringGetPDF. - Document store additions:
SetDocumentContent,SetOCRPDFStorageKey(non-if-empty variant),SetDocumentTranslation(content, from, to),ClearDocumentTranslation. Pre-existing bugs inPutDocument(missing commas in the ON CONFLICT clause) andCreateDocument/DeleteDocument(usingQueryinstead ofExec) were fixed while rewriting the store. - New
GET /user/project/ocr-pdf/:documentID— serves the OCR'd PDF (404 when none), mirroringGetPDF.
3. New Python services
ocr/ (lightweight, CPU-only; port of pdfutil.py with fixes):
POST /ocr— multipart in, SSE out. Fast path: pypdf text extraction; if empty text orforce=true:ocrmypdf.ocr(in, out, deskew=True, language=lang, force_ocr=force)(force_ocr avoids ocrmypdf's text-layer error that silently breaks the reference implementation). SSE events:{"type":"progress", ...}keepalives,{"type":"result", "text", "pdf_b64", "ocr_performed"},{"type":"error", ...}.- Single-concurrency lock like
tts/main.py. Dockerfile: python 3.12-slim +tesseract-ocr,tesseract-ocr-eng,tesseract-ocr-dan,ghostscript, pip:ocrmypdf,pypdf,fastapi,uvicorn(+.devvariant).
translator/ (mirrors tts structure incl. profile variants):
POST /translate— JSON{text, src_lang, tgt_lang}in, SSE out. Port oftranslation.py: NLLB pipeline (facebook/nllb-200-distilled-600M, FLORES-200 codes pass through directly), model loaded at startup (not import time), chunking improved: split on newlines + sentences, force-split oversized chunks at token limit.- SSE:
{"type":"progress","done":n,"total":m}per chunk + keepalives,{"type":"result","text"}, error events. Dockerfilecpu/cuda/rocm variants; gunicorn 1 worker + SSE keepalives so long jobs survive timeouts.compose/translator-profiles.yamlmirroringcompose/tts-profiles.yaml; HF cache volume../.translator-cache:/cache/huggingface.
4. Compose & CI
docker-compose.yaml: addocr(always on, backend network) andtranslator(default cpu template, cuda/rocm optional profiles)..env.example: nothing new required (no API keys — all local models)..forgejo/workflows/docker-build.yaml: build/push 5 images instead of 3 (addocr,translator).- Add short
ocr/README.md+translator/README.mdfollowingtts/README.mdstyle.
5. Frontend
- Text.tsx: wire the stubbed "Extract text" →
POST /user/project/ocr/{documentId}{force: checkboxValue}→ poll job (extractpollJobUntilDonefromSpeech.tsxinto a shared util) → refetch document (content now populated). Show link to OCR PDF (/user/project/ocr-pdf/{documentId}) whenocr_pdf_storage_keyis set. Note: OCR overwritescontent— add a confirm prompt if content is non-empty. "Force Optical Character Recognition" checkbox becomes functional. - New
Translate.tsxstep in the project flow (between Text and Speech): source language (defaultsdocument.language), target language dropdown (curated FLORES list,dan_Latn/eng_Latn+ common), translate button → poll → displaytranslated_content, from/to languages; re-translate and clear actions. types/document.ts/App.tsx: add the three new nullable fields.- Speech.tsx updated: polls project-scoped job status (
GET /project/text-to-speech/:id) instead of the removed name-based endpoint.Preview.tsxnow reads speech from/segments.AudioTestpage removed (only consumer of the generic free-text TTS endpoints).
6. Implementation order
- Migrations 009-rewrite, 011-blank, 013-new + models + job_store + port TTS worker/handlers to generic table — verify TTS still works end-to-end before proceeding.
- OCR service (Python) +
jobs/ocr.go+ endpoints + compose. - Translator service +
jobs/translation.go+ endpoints + compose + profiles. - Frontend (Text.tsx wiring, Translate.tsx, shared poll util, types).
- CI images, READMEs.
7. Verification
- Wipe
.databasevolume,docker compose up(dev override), run full flow: create doc → upload PDF → extract text (fast path + forced OCR) → verify OCR PDF at/data/{user}/{key}.pdf+content/ocr_pdf_storage_keyset → translate da→en → verify columns → TTS → playback. gofmt+ Go build +tscfrontend build.
Risks / notes
- Dev databases must be recreated (migrations rewritten) — confirmed OK.
- NLLB first run downloads ~2.4 GB; CPU translation of long docs is slow (progress column + SSE mitigate UX).
- OCR SSE result carries the PDF base64 (≤ ~27 MB for the 20 MB upload cap) — fine in practice.
- Danish OCR now gets the
dantesseract pack (the reference silently OCR'd Danish with English only).