# 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`](https://docs.getkenly.com/user-stories) (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`](https://docs.getkenly.com/design/automatic-translation-no-human-gate).

## 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}/status` endpoint emits — is the
> decision record [`docs/design/book-status-state-machine.md`](https://docs.getkenly.com/design/book-status-state-machine)
> (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`:

1. **Preprocess** — `IPagePreprocessor` (deskew/denoise/crop).
2. **Layout detect** — `ILayoutDetector` → ordered `PageBlock`s (type + bounding box, no text yet).
3. **OCR** — `IOcrService` → fills each block's `SourceText` + confidences; sets page OCR confidence.
4. **Language detect** — `ILanguageDetector` over the page's combined text → `DetectedLanguageCode`.
5. **Translate** — `ITranslationService` per block → `EnglishText` (block-by-block, never merged).
6. **Assemble + auto-approve** — `IPageReviewService` 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`/`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`](https://docs.getkenly.com/api-contract) 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`](https://docs.getkenly.com/product-vision) and [`brand/brand-guide.md`](https://docs.getkenly.com/brand/brand-guide).

- **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`](https://docs.getkenly.com/design/automatic-translation-no-human-gate).)
- **M2 (in progress):**
  - ✅ **Real PDF normalizer** — `PdfPageNormalizer` 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 worker** — `tools/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`](https://docs.getkenly.com/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 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. 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.
