Teaching AI to Read Cursive: The MDL Transcription Pipeline

1. Project Overview
The Marshall District Library (MDL) archive in Marshall, MI at archive.yourmdl.org holds scanned historical documents — high school yearbooks back to 1899, early Calhoun County church and cemetery records, township ledgers, school board minutes, financial records, diaries, and handwritten letters dating back to the 1800s. The content was digitized but not readable by machines. Cursive handwriting from that era defeats traditional OCR entirely.
I built two of the four custom Omeka S modules I maintain for this archive, TranscriptionManager and TypesenseSync, which together form a pipeline: scan a document, transcribe it with Claude via AWS Lambda, hold it in draft for review, publish it, and index it for sub-50ms full-text search. Researchers can now search for a name that only appears in a 150-year-old handwritten ledger and find it.
Live site: archive.yourmdl.org
2. Challenge / Problem
The archive had two problems:
Locked content. Page after page of handwritten cursive sat behind scanned PDFs. The information existed — names, dates, financial records, meeting minutes — but none of it was searchable. A researcher could only find a document if a librarian had manually tagged it with the right metadata. The actual handwritten text was invisible to search.
Inadequate search. Even for metadata that was tagged, the built-in Omeka S search was slow and lacked fuzzy matching, relevance ranking, or faceted filtering. Searching for "Walter Martin" wouldn't find "W. Martin" or handle a typo.
Traditional OCR tools like Tesseract can handle printed text, but 1800s cursive — with inconsistent letterforms, faded ink, and writer-specific styles — breaks them completely. The gap between what was in the archive and what was discoverable was enormous.
3. Design Decisions
Why decouple AI processing via Lambda instead of running it in PHP?
Omeka S is a PHP application with no built-in support for background workers or long-running processes. A single transcription job can take minutes to work through a multi-page document. Running that synchronously in PHP would block the web server and time out.
I used a chain-dispatch pattern: the module sends the first page to an AWS Lambda function with a short cURL timeout, then forgets about it. When Lambda finishes a page, it sends results back via webhook and the module fires the next page. Each page triggers the next one sequentially. The Omeka S server never blocks, the archivist can close the browser, and the job keeps running.
This also keeps costs predictable — sequential processing avoids rate limits and makes per-page costs easy to track.
Why a mandatory review checkpoint?
Claude is surprisingly good at 1800s cursive, but it's not perfect. It occasionally misreads faded letters, hallucinates text on damaged pages, or misaligns columns in dense ledger entries. Publishing raw AI output straight to a research archive would undermine trust.
So the module never does. When the last page comes back from Lambda, the job lands in draft and stops. Publishing is a separate, authenticated admin action from the review interface, and the publish handler refuses any job that isn't in draft. Nothing reaches the item as a media resource without someone choosing to send it.
The review interface itself is side-by-side: original scan on the left, AI-generated text on the right in an editable field, with edits saved per page. That's where an archivist corrects mistakes before publishing. What the code enforces is the checkpoint; how closely a given page gets read is a staff practice question, not something the module can guarantee.
Why Typesense over the built-in Omeka S search?
- Speed: Sub-50ms queries across the full archive vs. multi-second responses from MySQL full-text search
- Typo tolerance: Fuzzy matching handles misspellings automatically
- Faceted filtering: Filter by collection, document type, date range, tags
- Relevance ranking: Configurable field weights — titles rank highest, then extracted names, then metadata, then transcription text
- Highlighting: Search terms highlighted in results
I considered Elasticsearch but chose Typesense for lower resource requirements, simpler setup, and strong TypeScript support on the frontend.
4. Architecture Overview
Core pattern: Omeka S (headless CMS) → AWS Lambda (AI processing) → Human Review → Typesense (search index) → Next.js (frontend)
The TranscriptionManager code is public: the PHP module and its Lambda handlers are in omeka-s-module-transcription-manager on GitHub.
The Pipeline
Scan document → Upload to Omeka S → Select pages for transcription
→ Dispatch to Lambda (Claude) → Webhook returns results
→ Job lands in draft → Admin reviews side-by-side and publishes
→ Archivist runs a sync in TypesenseSync → Indexing endpoint rebuilds the index
→ Searchable on archive.yourmdl.org
TranscriptionManager Module (PHP)
- Adds transcription workflow UI to Omeka S admin
- Page selection interface with thumbnails
- Dispatches pages to API Gateway → Lambda via chain-dispatch pattern
- HMAC-SHA256 signed requests in both directions
- Webhook handler stores results and triggers next page
- Side-by-side review interface (scan image + editable transcription)
- Processing → Draft → Published lifecycle: publish refuses any job not in draft, unpublish reverts to draft
- Admin UI restricted to Editor and above; the only unauthenticated routes are the HMAC-signed Lambda callbacks and two read-only annotation endpoints that serve approved rows to the frontend
- Name annotation workflow: sends transcriptions back to Claude for bounding box coordinates, archivist reviews with SVG overlay tool
- Published transcriptions become first-class Omeka S media resources
TypesenseSync Module (PHP)
- Admin UI that triggers a sync from within Omeka S
- POSTs to a configured external indexing endpoint, which rebuilds the search index
- Keeps the Typesense collection schema and admin credentials outside Omeka S
- Logs sync status and errors to the Omeka S admin dashboard
Lambda Function (Python)
- Receives a page's IIIF Image API URL via API Gateway — no PDF download, no re-rendering
- Passes that URL to Claude as an image block through the Anthropic Messages API, with a cursive-tuned prompt
- Returns structured JSON: transcribed text, extracted person names, readability notes, and a table when the page holds one
- HMAC-SHA256 validation on incoming requests
- Sends results back to Omeka S via signed webhook
Security
Every request between Omeka S and Lambda is signed with HMAC-SHA256 using a shared secret. The module signs outgoing requests; Lambda validates them. Webhooks returning results use the same pattern in reverse. No unsigned requests get through.
5. Implementation Highlights
Chain-Dispatch Pattern
The key architectural decision. PHP can't run background jobs natively, and Omeka S doesn't have a queue system. Instead of bolting on a job runner, I made the webhook handler do double duty: store the result for the completed page, then immediately dispatch the next page. The chain runs itself.
This means a 40-page document processes as 40 sequential Lambda invocations, each triggered by the previous one's webhook. The Omeka S server handles only short HTTP requests — never a long-running process.
Name Annotation System
Beyond transcription, the module has a "Find Names" workflow. It sends completed transcriptions back to Claude, asking for bounding box coordinates of person names on the page image. The Lambda returns annotations with normalized coordinates (percentages of page dimensions), confidence scores, and parsed name components.
Archivists review annotations in a two-panel interface — page image with SVG overlay boxes on the left, approval list on the right. They can approve, reject, adjust, or manually draw annotations Claude missed. Approved annotations publish through a public API that the Next.js frontend consumes as clickable overlays on document pages.
Typesense Weighted Search
The indexing endpoint owns the collection schema, and with it the field weights that decide how results rank. TypesenseSync only kicks off the sync.
A search for "Walter Martin" returns:
- Documents with that name in the title (highest weight)
- Documents where the name was extracted by the annotation system
- Documents with the name in metadata fields
- Documents where the name appears somewhere in the transcription text (lowest weight)
This means a researcher finds the most relevant document first, not just the one with the most mentions.
6. Technical Stack Summary
Omeka S Modules (PHP):
- TranscriptionManager — transcription workflow, review UI, annotation system
- TypesenseSync — admin-triggered sync that hands off to the external indexing endpoint
AI Processing:
- AWS Lambda (Python)
- Claude via the Anthropic Messages API, image input by URL
- IIIF Image API for page images
Search:
- Typesense (self-hosted on EC2)
- Fuzzy matching, faceted filtering, field-weight ranking
Infrastructure:
- AWS API Gateway, Lambda, EC2
- HMAC-SHA256 request signing
- Webhook-based async communication
Frontend:
- Next.js (App Router) on Vercel
- Typesense client for search
- SVG annotation overlays
7. Conclusion
This pipeline turns locked historical documents into discoverable archive content. Researchers can now search for names, dates, and terms that only existed in 150-year-old cursive handwriting — content that was completely invisible to search before.
The architecture reflects a few principles I keep coming back to: decouple where the platform constrains you (Lambda for AI processing because PHP can't do background work), put a hard checkpoint where accuracy matters more than speed (a draft state nothing can skip on the way to publish), and choose tools that solve the actual problem (Typesense for search performance Omeka S couldn't provide).
Both modules are in production at archive.yourmdl.org, processing real documents for real researchers. The collection is expanding — more ledgers, letters, and records are queued for transcription.
💬 Questions about this project? Get in touch or book a meeting, or connect with me on 💼 LinkedIn and 🐙 GitHub.