Skip to content
CollectDev
Talk to us
All work

Document annotation service

Case study · Reference build

Upload a document, get a job id, and let an agent pipeline do the reading.

A document annotation service built to show the pattern end to end: a request that returns at once, a worker that does the slow work, deterministic extraction before any model is called, two model passes with different budgets, every output forced through a schema, and Postgres doing the job a message broker is usually hired for. It was built in a fixed time budget, and the write-up includes the review passes that found its own bugs.

What it is
Upload a PDF or spreadsheet, get a job id at once, poll for AI-extracted metadata: type, summary, entities, dates, key facts
Rob's role
Designed, built, and reviewed it in a fixed time budget as a reference implementation
Shape
One Express and Prisma codebase deployed as an API and a worker from one image, Postgres as queue and store, a Next.js interface

This is a reference implementation Rob Runka built to a brief, not a client system and not a product. No client is named and no usage is claimed. It is on this site because it shows, in a small system, how CollectDev builds and reviews an agent pipeline.

01

The problem the software solves

Reading a document with a model takes longer than any sensible HTTP request should. So the service accepts the upload, writes one row, and returns a job id in the same breath. A worker claims the row, does the reading, and writes the result back to it. The client polls, or listens to a live stream, and nothing about the slow part leaks into the fast part.

The interesting decisions are in what the worker does, in what order, and what it refuses to trust.

02

Three passes, in the right order

  1. Extract, in code

    The PDF text layer is pulled with a parser; a spreadsheet is serialised as its headers plus a bounded sample of rows, because the model needs to know what the data is, not read ten thousand lines. Page counts, sheet shapes, and row counts come from code, never from the model. A scanned PDF with no text layer falls back to the model's native document reading.

  2. Classify, cheaply

    A fast, inexpensive model reads a four-thousand-character excerpt and returns the document type and a confidence from zero to one. It is forced to answer through a tool call whose schema is generated from the same validation schema the worker uses, so it cannot answer in prose.

  3. Annotate, properly

    Only this pass pays for the full text on a stronger model. It returns the summary, typed entities (person, organisation, location, date, money, product), dates, and key facts, again through a tool call against the schema. The worker re-validates the output before it writes a row. The model never writes unvalidated JSON to the database.

The order is the point. Facts that code can establish are established by code. The cheap model is spent on the question it can answer from an excerpt. The expensive model is spent once, on the full text, and its answer is checked against a schema before it is believed.

03

Postgres is the queue

A queue broker would have added a dependency to run and a dual-write problem to reason about, without adding a capability the service needed. The job table already had to exist. So it does both jobs.

  • The job row is the system of record for the result, so it is the queue too. Workers claim rows with a locked, skip-locked select, so any number of them can run without a coordinator and without a broker to operate.
  • Retryable failures requeue with exponential backoff, five seconds doubling to a five-minute cap, until the attempt budget is spent. A corrupt or unsupported file fails on the first attempt with no retries, so one poison pill cannot block the queue.
  • Every job runs under a deadline that aborts the model call, so a hung provider cannot hold a worker lane. A reaper reclaims jobs whose worker died mid-run, requeueing those with budget left and parking the rest as failed with the error preserved.
  • Uploads are deduplicated by content hash. Identical bytes return the existing job rather than paying for a second pass.
  • Both processes shut down cleanly: the worker finishes in-flight jobs, the API drains its live streams, then the database connection closes.

The claim query, the backoff, and the reaper fit in about a hundred and thirty lines, which is small enough to hold in your head entirely. The condition for replacing it is written down too: push semantics, multiple regions, or sustained load in the hundreds of jobs a second.

04

What was built on top

  • Chat with up to five processed documents at once, streamed token by token, with answers citing the source file. Context is built from the stored extraction, so nothing is re-parsed per question, and the document context is cached at the provider so repeat turns cost a fraction of the first.
  • An entity index across every document, searchable, with a lowercased indexed column so the case-insensitive filter stays on the index.
  • Live job updates over a server-sent stream, with one poll loop per process fanning out to every open dashboard, and automatic fallback to polling if the stream drops.
  • A mirror of each upload in object storage and a download endpoint that returns the exact bytes, so an annotation can be checked against its source.

05

Cost and safety

  • Model tiering, hard truncation caps per stage, dedupe by content hash, and per-job token and timing accounting stored in the database, so cost and latency are queryable with SQL.
  • A bearer token on every endpoint but the health check, compared in constant time. The browser never sees it; the interface's server-side proxy injects it. Uploads and chat are rate-limited per client to cap spend from any single caller.
  • Three dedicated cloud identities, each able to read only its own secrets and its own bucket. A compromised API container cannot deploy services or act as another identity.
  • A labelled evaluation set, run through the real pipeline before and after any prompt or model change, with a minimum score that gates the build.
  • A mock provider that runs the entire architecture with no key and no cost, which is how the stack starts by default and how the queue is tested in CI.

06

How it was built, and what the review found

The build started from a written spec: the decomposition, the queue decision, the endpoint contract, the failure taxonomy, and an ordered list of what to add if time allowed. Coding agents did the typing. Rob made the architecture decisions, reviewed every change, and merged nothing without the unit suite, an integration suite that runs the real API and worker against a throwaway database branch, and, for any prompt or model change, the evaluation set passing.

After the build, Rob ran adversarial review and security-audit passes over the code and the live infrastructure. They found, and he fixed:

  • A time-of-check race in the upload dedupe, where two identical uploads landing together could both be processed.
  • A retry loop on poison-pill files that would have spent the whole retry budget on a document that could never succeed.
  • A privilege-escalation chain in the cloud configuration, where the public container held a role that could deploy services.
  • A rate-limiter bypass through a proxy-trust misconfiguration.

One limitation is documented rather than hidden. The per-job deadline interrupts at await points, which covers the model calls, the slow and flaky part. A pathological document that pins the CPU inside the PDF parser would hold its lane past the deadline. The fix, extraction in a process the lane can kill, is named as the first hardening ticket for untrusted uploads. Finding your own gaps, and writing them down, is part of the work.

07

What it is built with

  • TypeScript throughout
  • Express and Prisma, one codebase with two entrypoints, built into one container image and deployed as two services on Cloud Run
  • PostgreSQL on Neon as both queue and result store, with an ephemeral database branch per integration run
  • Zod schemas shared between the model's tool definitions, the worker's validation, the wire format, and the integration tests
  • Server-sent events for live job updates and streamed chat
  • A Next.js interface on Vercel that reaches the API only through a server-side proxy
  • Unit tests beside the code, an integration suite over the real processes, and a labelled evaluation set

This reference build is a small, inspectable example of our AI and workflow automation service.

Next: Stoke

Tell us what is taking too much time.

Describe the work in a short conversation. You get an honest read on whether software, an integration, or AI belongs, and a sketch of how we would attack it. Rob reads the brief before the call.