Skip to content

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)

PrincipleHow the code enforces it
Page-by-page, block-by-block translationITranslationService only ever receives one block; the pipeline never concatenates a page/book into one prompt.
Calibrate narrow, then scale automaticallyThe 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 unattendedBatch 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 gateThe 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 toolingDomain depends only on Core interfaces; concrete OCR/translation live behind adapters (fakes today, RunPod/Python later). Spec rule #8.
ResumableEntire 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 internalEvery 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 namesClarity 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}/status endpoint emits — is the decision record docs/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 completedNext batch size
0 (new project)1 page (calibration)
15 pages
225 pages
3100 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:

  1. PreprocessIPagePreprocessor (deskew/denoise/crop).
  2. Layout detectILayoutDetector → ordered PageBlocks (type + bounding box, no text yet).
  3. OCRIOcrService → fills each block's SourceText + confidences; sets page OCR confidence.
  4. Language detectILanguageDetector over the page's combined text → DetectedLanguageCode.
  5. TranslateITranslationService per block → EnglishText (block-by-block, never merged).
  6. Assemble + auto-approveIPageReviewService builds the read-only result package; it is persisted to review/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/WorkerInfrastructureCore. 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 + routeTransition
POST /projects {name, sourcePath}∅ → Created
GET /projects · GET /projects/{id}read
POST /projects/{id}/normalizeCreated → Calibrating
POST /projects/{id}/process-next-batch{Calibrating|Processing} → auto-confirm batch → Processing or Complete
POST /projects/{id}/pages/{pageId}/retryFailed page → Processing (the one explicit recovery control)
POST /projects/{id}/export {format?}export the finished translation (Markdown/PlainText/Html/Epub/All)
GET /projects/{id}/costmeasured per-page GPU time + $ samples
GET /projects/{id}/estimatewhole-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-readable code (e.g. project_wrong_state, insufficient_credit, project_not_found, validation). The shape is built in one place (ApiProblem + a registered IExceptionHandler), so it can't drift per endpoint. Switch on code, not on prose.
  • Idempotency is fully server-side (PBI #2120) — there is no client Idempotency-Key header. 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 a PagedResult envelope { 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") (no Asp.Versioning dependency). The same-origin SPA calls /v1 directly; a narrow server-side back-compat rewrite still accepts the old unversioned paths (so older clients / an older cached SPA keep working), so /projects is an alias for /v1/projects.
  • OpenAPI 3 document at GET /openapi/v1.json, generated by .NET 10's first-party Microsoft.AspNetCore.OpenApi (no Swashbuckle). Every operation has a stable operationId, 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 in src/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 in product-vision.md and brand/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 normalizerPdfPageNormalizer renders 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 workertools/python-ocr-worker/ocr_worker.py emits a fixed JSON contract (blocks: type/text/confidence/bbox) from a page image; engines surya, kraken, and a dependency-free fake for plumbing. C# OcrWorkerClient runs it via IExternalToolRunner, runs it once per page (shared by the layout + OCR adapters), and caches the result to ocr/page-NNNN.ocr.json (resumable). Enabled with BookTranslation:OcrEngine. The fake engine runs end-to-end today (no models); installing surya-ocr/kraken lights up real recognition — see tools/python-ocr-worker/README.md.
    • ⏳ Remaining: real language detection (fastText lid.176) + storing/showing it in review.
  • 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.
  • 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.py dispatching ocr / language / translate / health over the shared JSON contract; Surya/Kraken OCR, NLLB/MADLAD translation, fastText language; fake fallbacks; Dockerfile + requirements.txt + deploy README.md.
    • C# RunPod client + adapters (Infrastructure/RunPod/): RunPodClient (runsync + async /status poll, Bearer auth, per-call queue/exec-time logging), RunPodOcrClient (shared per-page call, resumable via ocr/*.ocr.json), RunPodTranslationService, RunPodLanguageDetector. When RunPodOptions is configured, all model work runs on RunPod — never locally — driven through the same IPageOcrSource adapters 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 /runsync contract) 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 cloud HttpClient needs no 100s timeout; RunPodClient enforces 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.

9. Testing strategy & status

The spec's "Required Tests" map to src/BookTranslation.Tests (all green):

Spec requirementTest
Calibration: a new project processes exactly one page firstNew_project_processes_exactly_one_page_before_any_approval
Each completed batch auto-confirms and widens the nextBatch_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 balancebilling cost-gate tests
Page OCR / language / translation produce outputProcessing_a_page_fills_ocr_language_and_translation
Export preserves page boundariesExport_includes_page_boundaries_for_approved_pages_only
Resumable from saved stateResumabilityTests
Batch ladder numbersBatchPolicyTests

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. See tools/sampling/README.md.
  • SampleCorpusTests validate 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 PdfPageNormalizer renders a sample PDF into real page images that flow through the pipeline (RealNormalizerTests proves 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 processor

The 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.