BookTranslationApp — Engineering Spec
Derived from
book_translation_workflow_spec.md. This is the build's source of truth: the source doc states the product intent; this document states how the code is structured to deliver it and how each milestone is verified. Where the two ever disagree, fix this file and the code together.For the user-facing story map — every user story, its status, and the coverage gaps — see
user-stories.md(kept in sync with the Azure DevOps board).
PIVOT (PBI #1455, 2026-06-25). Kenly is now a fully automatic translation service with no human-in-the-loop review. This document has been rewritten to reflect that. It retires the earlier "apprenticeship, not a batch job" invariant — the per-batch human approval gate that used to sit between every escalation. The user uploads, sees an upfront cost estimate, commits/pays, and the entire book translates automatically with zero per-page interaction; they then read and export the finished result. The batch ladder (1 → 5 → 25 → 100 → rest), the calibrate-on-one-page- first idea, the resumable state machine, and the page pipeline all survive — what changed is that escalation now advances automatically (gated only by affordability) instead of waiting for a human approval. The decision and exactly what changed in code are recorded in
design/automatic-translation-no-human-gate.md.
1. Goal
A C# / .NET application that ingests a book (PDF, scanned pages, or screenshots) and translates it page-by-page into English, preserving page order and extracting text and images, then assembles a finished, exportable translation fully automatically.
The product is an automatic translation service, not an apprenticeship. The correct behaviour is:
estimate the cost upfront → the user commits/pays → process one calibration page → automatically widen the batch → keep going until the whole book is done → the user reads and exports the result.
Translation runs end-to-end without per-page human interaction. The system still starts narrow (one page) to calibrate, but it escalates automatically rather than pausing for approval. The only thing that can pause an in-flight book is affordability — when the next batch would exceed the wallet balance, processing stops cleanly and prompts the user to add credit.
2. Guiding principles (enforced in code)
| Principle | How the code enforces it |
|---|---|
| Page-by-page, block-by-block translation | ITranslationService only ever receives one block; the pipeline never concatenates a page/book into one prompt. |
| Calibrate narrow, then scale automatically | The first batch is a single page; ProcessNextBatchAsync auto-confirms each completed batch (no human gate) and the next batch widens via BatchPolicy.NextBatchSize(approvalCount). |
| Escalate automatically, advance unattended | Batch size comes from BatchPolicy.NextBatchSize(approvalCount); approvalCount rises automatically on each completed batch, and IBatchAutoAdvancer (on the JobProcessor, with a Worker backstop) drives the next batch without waiting for a human. |
| Affordability is the only gate | The pre-commit cost estimate (#1375) + the wallet are the sole brake: when the next batch would exceed the balance, processing pauses cleanly (no error) and surfaces the "Add credit" CTA (#1396); topping up resumes it. |
| Replaceable model/OCR tooling | Domain depends only on Core interfaces; concrete OCR/translation live behind adapters (fakes today, RunPod/Python later). Spec rule #8. |
| Resumable | Entire state serializes to project.json; every transition persists. A fresh process continues from disk. Spec rule #10. |
| Explicit, disciplined C# | Explicit types, Async suffix, CancellationToken on every pipeline method, nullable reference types on. Spec rules #1–#3. |
| UTC everywhere internal | Every absolute instant stored/persisted/serialized/logged/compared is UTC, named with an AtUtc postfix (CreatedAtUtc, UpdatedAtUtc, ExpiresAtUtc). Time comes from TimeProvider.GetUtcNow(); columns are timestamp with time zone. There is no internal local time — zoned display is computed on the fly on the frontend from the user's zone, and converted back to UTC before any write. DateTime.Now/.ToLocalTime()/TimeZoneInfo.Local are banned in the backend. |
| Explicit, verbose names | Clarity beats brevity for every identifier — spell things out (approvalCount, ProcessNextBatchAsync, nowUtc) rather than abbreviate. A longer unambiguous name always wins over a short one that needs a comment. |
3. The workflow as a state machine
The whole product is modelled as an explicit, stepwise state machine that runs to completion automatically. Every operation is still one discrete, guarded, persisted transition — so the flow stays resumable — but, post-pivot, the batch transitions chain themselves: completing a batch auto-confirms it and the next batch is enqueued automatically (no human approval between them). The only thing that stops the chain is affordability (next batch over balance → clean pause) or a failed page (left for explicit retry). Normalizing still does not auto-start processing.
3.1 Project states (ProjectStatus)
CreateProject
│
▼
Created
│ Normalize (produce ordered pages, all Pending)
▼
Calibrating
│ ProcessNextBatch (process 1 calibration page,
│ auto-confirm: approvalCount++ → widen the ladder)
▼
Processing ──── ProcessNextBatch (auto-advanced) ──┐
▲ (each batch auto-confirms and the next │
│ is enqueued by IBatchAutoAdvancer) │
└──────────────────────────────────────────-─┘
│
│ (no Pending pages remain after the last batch)
▼
Complete
Affordability brake: when the next batch would exceed the wallet balance,
the auto-advance pauses cleanly at Processing (no error) and shows the
"Add credit" CTA; a top-up resumes the chain.There is no human review/approval state — translation is fully automatic (PBI #1455). Illegal transitions throw InvalidProjectStateException (surfaced by the API as 409 Conflict) rather than silently misbehaving.
The authoritative state machine — every state with its kind, the complete legal transition table, and the merged, agent-facing status DTO the
GET /v1/projects/{id}/statusendpoint emits — is the decision recorddocs/design/book-status-state-machine.md(Spike #1999). It is what the status-endpoint (#2000) and transition-table-consolidation (#2001) PBIs execute against.
3.2 Page states (PageStatus)
Pending ──► Processing ──► Approved (system auto-approves a processed page)
(any step error) ──────► Failed ──► (explicit Retry) ──► Processing …There is no human decision in the automatic flow: a page that processes cleanly is auto-approved by the system. A page that errors becomes Failed and is recovered by the one remaining explicit control — retry.
3.3 Batch-escalation ladder (BatchPolicy)
| Batches completed | Next batch size |
|---|---|
| 0 (new project) | 1 page (calibration) |
| 1 | 5 pages |
| 2 | 25 pages |
| 3 | 100 pages |
| 4+ | all remaining pages (stable) |
The ladder still starts narrow — one calibration page — and widens as batches complete, but each batch now auto-confirms (increments the count that widens the next batch) instead of waiting for a human approval. The size is always capped to the number of Pending pages actually left. (BatchPolicy is unchanged; only the trigger that advances it changed — from human approval to automatic confirmation.)
4. Per-page pipeline (inside ProcessNextBatchAsync)
Each page is walked through an explicit, logged sequence; on any error the page becomes Failed:
- Preprocess —
IPagePreprocessor(deskew/denoise/crop). - Layout detect —
ILayoutDetector→ orderedPageBlocks (type + bounding box, no text yet). - OCR —
IOcrService→ fills each block'sSourceText+ confidences; sets page OCR confidence. - Language detect —
ILanguageDetectorover the page's combined text →DetectedLanguageCode. - Translate —
ITranslationServiceper block →EnglishText(block-by-block, never merged). - Assemble + auto-approve —
IPageReviewServicebuilds the read-only result package; it is persisted toreview/page-NNNN.review.json; the page is auto-approved by the system (no human gate). When every page in the batch has processed, the batch auto-confirms and the next batch is enqueued automatically (subject to the affordability brake).
5. Architecture
BookTranslationApp (repo root)
BookTranslationApp.slnx ← .NET 10 solution (modern XML format)
Directory.Build.props ← shared: net10.0, nullable, XML docs
src/
BookTranslation.Core/ ← domain models, enums, stage interfaces, BatchPolicy,
ITranslationPipeline (NO external dependencies)
BookTranslation.Infrastructure/← fake adapters, FileSystemProjectStore, TranslationPipeline,
PageReviewService, MarkdownExportService, ProcessExternalToolRunner, DI
BookTranslation.Api/ ← ASP.NET Core Minimal API; one endpoint per transition
BookTranslation.Worker/ ← background processor; auto-advances in-flight projects to completion
BookTranslation.Tests/ ← xUnit; drives the real state machine over a temp store
tools/
python-ocr-worker/ ← (later) Python OCR/model scripts
data/
projects/{projectId}/ ← per-project artifacts (see §6)Dependency direction: Api/Worker → Infrastructure → Core. Core depends on nothing. The pipeline only ever sees Core interfaces, so adapters swap with zero pipeline changes.
6. On-disk layout (per project) — the resumable state
data/projects/{projectId}/
project.json ← serialized TranslationProject = the resumable state
source/ ← original input
pages/ ← normalized page images (page-0001.png …)
preprocessed/ ← cleaned page images
assets/ ← extracted images/figures
review/ ← page-0001.review.json (one per processed page)
exports/ ← book.en.md (and later .docx/.epub/.pdf)Writes to project.json are atomic (temp file + move) so a crash can't corrupt saved state.
7. HTTP API (one endpoint per transition)
| Method + route | Transition |
|---|---|
POST /projects {name, sourcePath} | ∅ → Created |
GET /projects · GET /projects/{id} | read |
POST /projects/{id}/normalize | Created → Calibrating |
POST /projects/{id}/process-next-batch | {Calibrating|Processing} → auto-confirm batch → Processing or Complete |
POST /projects/{id}/pages/{pageId}/retry | Failed page → Processing (the one explicit recovery control) |
POST /projects/{id}/export {format?} | export the finished translation (Markdown/PlainText/Html/Epub/All) |
GET /projects/{id}/cost | measured per-page GPU time + $ samples |
GET /projects/{id}/estimate | whole-book cost/time projection from the samples |
Guard violations → 409; bad input → 400. All routes are served under a versioned /v1 base path (e.g. POST /v1/projects) — see §7.2.
7.1 Programmatic contract (PBI #1428)
The surface is hardened for non-interactive agent/MCP clients — see docs/api-contract.md for the full reference:
- Errors are RFC 9457
ProblemDetails(application/problem+json) with a stable machine-readablecode(e.g.project_wrong_state,insufficient_credit,project_not_found,validation). The shape is built in one place (ApiProblem+ a registeredIExceptionHandler), so it can't drift per endpoint. Switch oncode, not on prose. - Idempotency is fully server-side (PBI #2120) — there is no client
Idempotency-Keyheader. For every state-changing POST (normalize,process,process-next-batch,export,retry,cancel, the wallet purchase) the server derives its own dedup key from the request content + current state, so a double-submit applies exactly once (fail-open if Redis is down). - List convention: collections return a bare JSON array, except the searchable, paginated project library (
GET /projects), which returns aPagedResultenvelope{ items, total, page, pageSize }. - Discovery:
GET /v1/meta(anonymous) returns the API version, the batch ladder, and the supported export formats so a client can self-configure.
7.2 Versioning + OpenAPI (PBI #1427)
The whole public surface lives under a stable /v1 base path so a future breaking change can ship as /v2 without silently breaking existing agents/SDKs.
- Routing: a plain
app.MapGroup("/v1")(noAsp.Versioningdependency). The same-origin SPA calls/v1directly; a narrow server-side back-compat rewrite still accepts the old unversioned paths (so older clients / an older cached SPA keep working), so/projectsis an alias for/v1/projects. - OpenAPI 3 document at
GET /openapi/v1.json, generated by .NET 10's first-partyMicrosoft.AspNetCore.OpenApi(no Swashbuckle). Every operation has a stableoperationId, a resource tag, and typed request/response +ProblemDetails(402/404/409) responses; the document declares the JWT bearer and API-key security schemes so a generator/MCP client knows how to authenticate. Per-endpoint metadata is set with.WithName/.WithTags/.Produces/.ProducesProblem; cross-cutting metadata (operationId/tag/security defaults) is filled by two transformers insrc/BookTranslation.Api/OpenApiConfiguration.cs.
8. Milestones
Engine vs. product: the milestones below scope the translation engine. The user-facing surfaces (web app on
getkenly.com, public API GA, MCP server for agents, token billing, native desktop apps) and the brand (the product is now Kenly) are scoped separately inproduct-vision.mdandbrand/brand-guide.md.
- M1 (this build): local filesystem, fake adapters. Create → normalize → process one calibration page → auto-confirm → auto-advance through the widening batches → Markdown export. ✅ Implemented + tested. (Originally shipped with a human approval gate between batches; the gate was removed in the PBI #1455 pivot — see
design/automatic-translation-no-human-gate.md.) - M2 (in progress):
- ✅ Real PDF normalizer —
PdfPageNormalizerrenders a real book PDF (or an image folder) into ordered page PNGs via PDFium (PDFtoImage + SkiaSharp), in-process in C#; on by default in the API (BookTranslation:UseRealPdfNormalizer). - ✅ Real OCR + layout via an external Python worker —
tools/python-ocr-worker/ocr_worker.pyemits a fixed JSON contract (blocks: type/text/confidence/bbox) from a page image; engines surya, kraken, and a dependency-free fake for plumbing. C#OcrWorkerClientruns it viaIExternalToolRunner, runs it once per page (shared by the layout + OCR adapters), and caches the result toocr/page-NNNN.ocr.json(resumable). Enabled withBookTranslation:OcrEngine. Thefakeengine runs end-to-end today (no models); installingsurya-ocr/krakenlights up real recognition — seetools/python-ocr-worker/README.md. - ⏳ Remaining: real language detection (fastText lid.176) + storing/showing it in review.
- ✅ Real PDF normalizer —
- Cross-cutting (done):
- ✅ Cost estimation. Every RunPod call's GPU time is metered per page (
cost/samples.jsonl); the estimator drops the warmup page and projects whole-book cost/time. Verified: ~$0.001/page on an RTX 3090 → a 970-page Latin book ≈ $0.97.GET /projects/{id}/cost+/estimate. - ✅ Multi-format book assembly. Approved pages →
book.en.{md,txt,html,epub}in page order, each page's original scan beside its English translation; the EPUB bundles the page images inside (archive.org-style).MultiFormatExportService,POST …/export {format: All}. - 📈 Workflow diagram:
workflow.svg.
- ✅ Cost estimation. Every RunPod call's GPU time is metered per page (
- M3: real translation (MADLAD-400 / NLLB-200, optional LLM polish); manual edits; DOCX export.
- M4 (in progress): RunPod cloud models.
- ✅ Serverless worker (
tools/runpod-worker/):handler.pydispatchingocr/language/translate/healthover the shared JSON contract; Surya/Kraken OCR, NLLB/MADLAD translation, fastText language;fakefallbacks;Dockerfile+requirements.txt+ deployREADME.md. - ✅ C# RunPod client + adapters (
Infrastructure/RunPod/):RunPodClient(runsync + async/statuspoll, Bearer auth, per-call queue/exec-time logging),RunPodOcrClient(shared per-page call, resumable viaocr/*.ocr.json),RunPodTranslationService,RunPodLanguageDetector. WhenRunPodOptionsis configured, all model work runs on RunPod — never locally — driven through the sameIPageOcrSourceadapters as the local worker. Verified by 8 mock-HTTP tests. - ✅ Live pod round-trip proven: deployed a real RunPod GPU pod (RTX 3090), ran
server.py(mimics the serverless/runsynccontract) via the pod proxy, and drove the full C# pipeline end-to-end on a real Gesner Latin page — OCR + language + translation all executed on the GPU and returned into the review package. (Fixed a real bug: the cloudHttpClientneeds no 100s timeout;RunPodClientenforces its own.) The pod path needs no Docker image —pip install+server.py. - ✅ Real Surya OCR + NLLB + py3langid achieved on a pod. A 1551 Gesner Latin page ran end-to-end through the C# pipeline on an RTX 3090: real Surya OCR (64 Latin text lines, conf 0.91, ~79s incl. model download), py3langid language detection (Latin, conf 1.0), and NLLB translation — all on the GPU, nothing model-heavy local. The working dependency matrix (torch 2.6 + torchvision 0.21 cu124, surya-ocr 0.10, transformers 4.46, py3langid replacing fastText) is pinned in
tools/runpod-worker/{requirements.txt,pod_setup.sh}. NLLB-600M's Latin is weak (the spec's optional LLM polish pass is the upgrade); Kraken is the better OCR for historical type.
- ✅ Serverless worker (
9. Testing strategy & status
The spec's "Required Tests" map to src/BookTranslation.Tests (all green):
| Spec requirement | Test |
|---|---|
| Calibration: a new project processes exactly one page first | New_project_processes_exactly_one_page_before_any_approval |
| Each completed batch auto-confirms and widens the next | Batch_size_increases_only_after_approval, Approving_a_full_batch_increments_approval_count_and_advances_status |
| The chain auto-advances to completion (no human gate) | auto-advance / IBatchAutoAdvancer tests |
| Affordability brake pauses cleanly when over balance | billing cost-gate tests |
| Page OCR / language / translation produce output | Processing_a_page_fills_ocr_language_and_translation |
| Export preserves page boundaries | Export_includes_page_boundaries_for_approved_pages_only |
| Resumable from saved state | ResumabilityTests |
| Batch ladder numbers | BatchPolicyTests |
Test names that still reference "approval" predate the #1455 pivot; in the automatic flow the "approval" is the system's auto-confirmation of a completed batch, not a human action.
9.1 Real-book sample corpus
Beyond the synthetic fakes, the pipeline is exercised against real book pages. tools/sampling/ make_samples.py carves a source PDF into a corpus of small, multi-size sample PDFs — a few contiguous page ranges at each size 1..10 — driven by a fixed seed so the selection is reproducible.
- Corpora (all public-domain bestiaries, mixing Latin/English + woodcut illustrations): Historia Serpentum et Draconum (Aldrovandi, 1640; Latin; 467 pp; seed 1640), The History of Four-Footed Beasts (Topsell, 1658; English; 1102 pp; seed 1658), Historia Animalium (Gesner, 1551–58; Latin; 970 pp; seed 1551) — each 30 samples (3 × sizes 1–10).
- The sample PDFs (~40 MB of scans) are git-ignored but regenerable; the seeded script and the manifest (
tools/sampling/manifests/<slug>.json, with source + per-sample sha256) are tracked, so the corpus reproduces byte-for-byte. Seetools/sampling/README.md. SampleCorpusTestsvalidate the manifest (sizes covered, ranges in-bounds, ids unique) always, and verify the on-disk PDFs match the manifest when the corpus has been generated locally.- These are live pipeline inputs: the Milestone-2
PdfPageNormalizerrenders a sample PDF into real page images that flow through the pipeline (RealNormalizerTestsproves it on a Topsell sample). Adding another book later = rerun the generator with a new source.
10. Running it
bash
# from the repo root
dotnet test BookTranslationApp.slnx # run all tests
dotnet run --project src/BookTranslation.Api # start the API (see / for the endpoint list)
dotnet run --project src/BookTranslation.Worker # start the background batch processorThe API defaults its data root to <repo>/data; override with BookTranslation__DataRoot or the BookTranslation:DataRoot config key. With fake adapters the normalizer synthesizes 5 pages, so the full flow is demoable without any real input files.