What Does This System Do?
When a funeral booking is placed, this system reads every file the customer uploaded, looks up any missing hymn or prayer text, applies branch-specific formatting rules, and produces a ready-to-use InDesign file, entirely automatically. No manual typing. No copy-pasting.
What it handles automatically
| Customer provides | System does |
|---|---|
| A Word document with service notes | Reads and extracts all text from the document |
| A photo of handwritten instructions | Scans the image using OCR to read the text |
| Just a hymn title (e.g. "Jerusalem") | Checks the internal content library; inserts full text if found, or the title only if not found |
| A name in lowercase (e.g. "john smith") | Applies branch rules, e.g. outputs "JOHN SMITH" if capitalisation is required |
| No running order specified | Uses the branch's typical page layout as a guide for content distribution |
| A modern copyrighted song | Inserts a placeholder and flags it as a potential data entry error |
Full Business Walkthrough
Everything that happens from the moment a booking is placed to the moment a designer opens the InDesign file. This section explains every decision point, how edge cases are handled, and what happens when things don't go as expected.
The Journey of a Single Order
Every booklet order passes through the same eight stages. Here is each step in plain English with a real-world example throughout.
instructions.docx and handwritten_note.jpg./process endpoint. This happens within seconds of the booking being created, with no manual triggering required.instructions.docx → full text extracted.handwritten_note.jpg → OCR reads "please include The Lord's Prayer on page 6".ELEANOR VANCE (branch capitalisation rule applied).Running order followed: Welcome → Hymn → Eulogy → Poem → Lord's Prayer → Committal.
"A custom poem" → not in library → title only inserted; designer adds the body.
"Angels by Robbie Williams" → modern copyright → placeholder inserted + note added.
Attachment Processing
The system handles three types of file attachment, each processed differently.
| File type | How it's read | Works well for | Limitations |
|---|---|---|---|
| .docx Word document | Parsed with the mammoth library. Full text extracted directly. |
Any standard Word document: typed running orders, service notes, tributes. | Heavily formatted tables or unusual embedded content may not extract cleanly. |
| .pdf PDF document | Text layer extracted with pdf-parse. |
Digitally created PDFs where text was typed directly. | Scanned/image PDFs (a photo saved as PDF) return no text; the image inside would need OCR. |
| .jpg / .png / .webp Image | Scanned locally with Tesseract.js OCR (runs on the server, no external API). | Photos of printed or clearly typed text, neatly written handwritten notes. | Heavily cursive or scrawled handwriting may not extract reliably. Claude Vision is available as an upgrade path for difficult handwriting. |
Formatting & Rules Priority
The system uses a three-tier instruction hierarchy to decide which rules take precedence when there is a conflict.
| Priority | Rule source | What it controls | Can it be overridden? |
|---|---|---|---|
| Level 1 (Highest) | Customer's running order / page sequence | Which content goes on which page and in what order. | No. If the customer specifies a sequence, it is followed exactly and overrides the consensus layout. |
| Level 2 | Branch-specific formatting rules (Branch/<id>.txt) |
Capitalisation, spelling, bold/italic conventions, date formats, branch-specific preferences. | Only by master rules where they conflict. The customer supplies the data; the branch rules govern how it is presented. |
| Level 3 | Master rules (master/master.txt) |
Global standards applied to every order from every branch. | No; these are the baseline for everything. |
How to update rules
Rules are plain text files written in plain English; Claude reads and interprets them directly. To change a rule for a specific branch, edit the file at Branch/<arranger_id>.txt on the server. To change global rules, edit master/master.txt. Changes take effect immediately on the next order, with no restart required.
Copyright & Music Handling
Funeral booklets frequently include hymns and prayers referenced only by their title. The system handles these in two ways depending on copyright status.
- System checks the internal content library and inserts the full text if found.
- If not found in the library, the title is placed in the field as-is; the designer inserts the body text manually.
- Prayer titles are bolded automatically.
- System does not reproduce lyrics and does not search for them.
- Inserts a placeholder:
[Lyrics of "Song Title" by Artist. Please insert manually due to copyright.] - A note is added to the job flagging it as a potential data entry error; modern pop songs on a funeral booklet may have been entered by mistake.
What Happens When Things Go Wrong
The system is designed to degrade gracefully; a single problem with one part of an order does not stop the whole job.
| What goes wrong | What the system does | What the designer sees |
|---|---|---|
| A file URL fails to download | Skips that file, continues processing all other attachments. | Booklet populated without that file's content. May need manual additions. |
| OCR finds no readable text in an image | Logs a warning, continues without that image's content. | Content from that image will be missing. Check the original file. |
| Content is referenced by title only and is not in the library | The title is inserted into the field. No external search is made. | Title appears in the booklet; designer adds the full text if needed. |
| The order has no product name | Immediately exits: no AI call, no processing. Logged as "Not an OOS". | Not a booklet order; skipped silently. |
| AI returns malformed XML | Validation catches it. Returns HTTP 422 to Switch. Switch routes to manual review path. | Job flagged for manual review in Switch; does not reach InDesign with broken data. |
Technical Architecture
A Node.js/Express service running on Ubuntu, coordinating document parsing, OCR, AI generation, and XML validation in a single synchronous request–response cycle. The full pipeline completes in approximately 15–30 seconds per order.
/process endpoint.Technology Stack
ANTHROPIC_API_KEY)Key Design Decisions
| Decision | Reason |
|---|---|
| Prompt caching on system prompt | System prompt is static per branch; order data is dynamic. Cache hit on every repeat order from the same branch, roughly 10x cheaper on input tokens for the static portion. |
| Tool use for library lookup | Claude triggers the library lookup tool only when it identifies a title with no body text; no unnecessary lookups on orders where all content is already provided. |
| Local OCR via Tesseract.js | Zero cost, no external API calls, and customer images never leave the server. Fast enough for the expected image volume. |
| File-based rules (plain text) | No code changes or server restarts needed to update formatting instructions. Any team member can edit a branch rule file immediately. |
| XML validation before return | Prevents silent failures. Malformed XML reaching InDesign Server would cause invisible errors in the final print file; catching it at this stage allows Switch to route the job to manual review instead. |
| Heuristic OCR classifier | Replaced a Claude API call (used for classifying OCR output as relevant or noise) with a regex/word-count heuristic. Eliminates a small but unnecessary API cost on every image attachment. |
Service Ports
| Service | Port | Visibility | Notes |
|---|---|---|---|
| Express Middleware (index.js) | 3020 | Public (via Nginx proxy) | Receives HTTP POST from Enfocus Switch. All order processing happens here. |
| SQLite Database | 8105 | Localhost only | Bound to 127.0.0.1. Closed to external traffic for security. Admin UI accessible at /_/. |
How the AI Works
Claude is the decision-making engine at the centre of every order. It receives the blank template, the full order data, all extracted attachment text, and all loaded formatting rules in a single structured API call — and returns a fully populated XML document. This section explains exactly what Claude sees, what it decides, and how it handles gaps in the supplied information.
Model & Cost
| Model | Use case | Approx. cost / order | How to activate |
|---|---|---|---|
claude-haiku-4-5 |
Default. Fast, low cost, strong XML generation and instruction following. Handles the vast majority of orders with excellent results. | ~4p | Automatic — no parameter needed |
claude-sonnet-4-6 |
Higher intelligence for complex orders: ambiguous or conflicting instructions, lengthy multi-section tributes, orders requiring more editorial judgement. | ~20–30p | Append ?model=claude-sonnet-4-6 to the Switch endpoint URL |
What Claude Receives
Every API call is structured as two blocks: a system prompt (static per branch, prompt-cached) and a user message (unique per order, never cached). Together they give Claude everything it needs to complete the job in a single call with no follow-up questions.
- Rules 1–8: base persona, XML structure, priority hierarchy, library lookup behaviour, copyright safety, headers-only mode
- Global master rules from
master/master.txt - Branch-specific rules from
Branch/<id>.txt - Consensus layout model, if one exists for this branch
- The blank XML template to be populated
- Order data: deceased name, dates, arranger ID, product name
- All extracted attachment text: Word documents, PDFs, and OCR output from images — concatenated into a single block
What Claude Does — Step by Step
<NameOfDeceased>, <Story>, <Page2> through <Page7>, <BackPageMessage>, and so on. The template is self-describing — Claude infers the purpose of each tag from its name and the surrounding rules, with no separate schema explanation required.1940-02-12 → 12th February 1940), bolding prayer titles, and so on. The customer's running order — if present in the order data or in any attachment — determines which content appears on which page. If no running order is given, the consensus layout model is used as a structural guide.web_search tool to check the internal content library. If the piece is found, the stored text is returned as a reference token. If not found, Claude is instructed to populate the field with the title only. For modern copyrighted songs, no library check is made — a placeholder is inserted directly and a note is added for the designer."A custom poem" → not in library → title only inserted
"Angels by Robbie Williams" → modern copyright → placeholder inserted + note flagged
web_search tool, the middleware intercepts the call and checks the internal Content Library for a matching record. If found, a {{LIB:N}} reference token is returned to Claude immediately — no network call, no cost, no latency. The token is expanded into the full body text after generation. If nothing matches, the middleware returns a "title only" instruction: Claude places just the title in the field and does not attempt to look up or write the full text from any other source.→ Middleware checks Content Library → record found
→ Token {{LIB:1}} returned; full text injected after generation
Tool call flow (no library match)Claude → web_search("A poem we wrote ourselves")
→ Middleware checks Content Library → no match
→ "Title only" instruction returned to Claude
→ Title inserted; designer adds the body text
fast-xml-parser, and returns it to Switch. If the XML is valid, HTTP 200 is returned and the job is logged as success. If validation fails (unclosed tag, encoding error, etc.), HTTP 422 is returned with the fault location, and Switch routes the job to manual review — preventing broken data from reaching InDesign.Why AI and Not a Rules Engine or Templates
1940-02-12 → 12th February 1940). When a hymn, prayer, or poem is referenced by title only, the content library is checked — if found, the stored text is used; if not found, only the title is inserted. It never invents names, dates, tributes, or any memorial content.Technical Reference
Full specifications for every component: server configuration, the complete API contract, OCR pipeline, rules engine, consensus model format, web scraper, database schema, and testing procedures.
Managing Rules & Branches
No code changes or server restarts are needed to update formatting rules or add a new branch. All rule files are loaded fresh from disk on every request.
Updating Global Formatting Rules
Edit the file at /var/www/middleware.justdigital.co.uk/master/master.txt. Changes take effect immediately on the next job. This file applies to every order regardless of branch.
Adding or Updating a Branch (Arranger)
Create or edit a plain text file at /var/www/middleware.justdigital.co.uk/Branch/<arranger_id>.txt. For example, Arranger ID 9 reads from Branch/9.txt. Write instructions in plain English; Claude reads and applies them directly. If no file exists for an arranger, the system falls back to master rules only.
Adding a Consensus Layout Model
Create a JSON file at Branch/<arranger_id>_consensus-model.json. See the Consensus Layout Model section for the full file format.
Adding a New InDesign Template
Add a row to FinalTemplate.csv with the product name, variant name, and the corresponding XML template filename. Place the XML template file in the templates/ directory. The middleware picks it up automatically on the next matching order.
Manually Correcting Output XML
If a job produces malformed or incorrect XML (e.g. a validation error, a missing section, or a copyright placeholder that needs replacing), the output can be edited directly in the dashboard without re-running the job. On any row in the job table, click ✎ Edit XML to open a full-screen editor pre-populated with the current output. Save the corrected XML and it will be persisted back to the audit record. The corrected XML can then be copied into Switch or InDesign manually.
Testing Without Sending to InDesign
Append ?mock=true to the endpoint URL in Enfocus Switch: POST /process?mock=true. The system runs through all file downloads and validation steps but skips the Claude API call and returns the unpopulated XML template. Safe to use in production; logs to the database with status success (mock) and costs nothing in API fees.
Database API Security Rules
| Operation | Rule | Reason |
|---|---|---|
| List / View | @request.auth.id != "" | Dashboard requires a logged-in user to read records. |
| Create | "" (open) | Must stay open. The middleware writes job logs directly to the database without a user token (server-to-server on localhost). Locking this will silently break all job logging. |
| Update | null (locked) | Nothing in the system ever updates a log record after creation. |
| Delete | null (locked) | Prevents accidental or malicious deletion of the audit trail. |
Server Configuration & Routing
- Middleware Express App: Listens on port
3020, publicly proxied for Enfocus Switch connections. - SQLite Database: Listens on port
8105, bound locally tohttp://localhost:8105, closed to external traffic.
.env file in the root folder:ANTHROPIC_API_KEY=your_claude_api_key
Network Traffic Flow
[ Enfocus Switch Client ]
│ (JSON Payload via HTTP POST)
▼
[ Ubuntu Server Firewall (Port 3020) ]
│
▼
[ Express Middleware Service (index.js) ]
│
├── (localhost:8105) ──► [ SQLite Database: job logs ]
│
├── (Anthropic API) ──► [ Claude Haiku 4.5: XML generation ]
│
└── Compiled XML ──► Returned to [ Enfocus Switch Client ]
API Specification
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
mock | Boolean | No | If true, runs in dry-run mode. Downloads assets but skips Claude and OCR, returning the unpopulated template. |
model | String | No | Specifies the Claude model. Defaults to claude-haiku-4-5. Override: claude-sonnet-4-6. |
Body Parameters (JSON)
| Parameter | Type | Required | Description |
|---|---|---|---|
arranger_id | String | No | Used to load branch-specific rules from Branch/<arranger_id>.txt. |
order_line_item_1_product_name | String | Yes | Combined with variant name to resolve the correct template. If missing or empty, middleware exits immediately with status "Not an OOS"; no AI call is made. |
media_location_X | URL | No | Any key starting with media_location_ triggers download and text extraction of the linked file. |
InDesign Template Mapping (FinalTemplate.csv)
| CSV Column | Matching Logic | Description |
|---|---|---|
Product Name | Exact match | Compared against order_line_item_1_product_name. |
Variant Name | Exact match | Compared against order_line_item_1_product_variant_name. |
Template File | Output | Resolves the target XML template filename (e.g. 4_page_standard.xml). |
templates/ directory (e.g. the CSV references a file that has been renamed or not yet uploaded), the middleware automatically falls back to master.xml. The fallback is logged to the console and the _template_used field in the audit record is updated to master.xml, so the dashboard always reflects the template that was actually used.{
"id": "10092",
"arranger_id": "9",
"order_line_item_1_product_name": "Arrangement 9 Booklet - 4 Page",
"order_line_item_1_product_variant_name": "Standard",
"deceased_name": "Eleanor Vance",
"date_of_birth": "1940-02-12",
"date_of_death": "2026-05-18",
"media_location_1": "http://assets.justdigital.co.uk/orders/10092/instructions.docx",
"media_location_2": "http://assets.justdigital.co.uk/orders/10092/handwritten_note.jpg"
}
XML Element Schema
| XML Tag | Purpose | Sample Output |
|---|---|---|
<FrontCoverDesign> | URL of the front cover InDesign template layout file. | http://assets.../front_cover.indd |
<Image1> | URL of the customer-supplied photograph (href attribute; never modified by the AI). | <Image1 href="http://...photo.jpg"> |
<NameOfDeceased> | Full name, formatted per branch capitalisation rules. | ELEANOR VANCE |
<Story> | Contains <DateOfBirth> and <DateOfDeath> for the front cover dates line. | 12th February 1940 – 18th May 2026 |
<BothInnerPagesDesign> | URL of the inner pages InDesign template layout file. | http://assets.../inner_pages.indd |
<Page2> – <Page7> | Sequential booklet page content. Claude distributes service text across these six pages. | HYMN: Abide With Me\n[Full lyrics] |
<BackPageMessage> | Back cover text: refreshment details, thank-you note, charity requests. | Refreshments at The Crown Hotel... |
Response Format
| Field | Type | Description |
|---|---|---|
success | Boolean | true on success, false on error. |
xml | String (XML) | The fully populated, validated XML document ready for InDesign Server. |
notes | String | Operator notes from Claude: copyright warnings, manual-insert placeholders, or flags for unusual content. Empty string if none. |
error | String | Present only on failure. Human-readable error (e.g. Invalid XML: unclosed tag (line 12, col 3)). HTTP 422 is returned. |
Test the middleware directly from your browser. Triggers a live request to the middleware service.
XMLValidator parses it. Valid XML returns HTTP 200 and is logged as status: "success". Invalid XML returns HTTP 422 with the fault location (line and column) so Switch can route the job to a manual review path rather than passing broken data to InDesign.
Tools & Services Reference
Every tool in this system was chosen for a specific reason. This section explains what each one actually does in plain English — not just its name, but its role in the pipeline and why it was selected over alternatives.
Node.js — The Runtime
Node.js is a JavaScript runtime that executes outside the browser, directly on the server. It is built around a non-blocking, asynchronous I/O model: rather than waiting for one task to finish before starting the next, Node can kick off multiple operations simultaneously — downloading three attachments, waiting for the Claude API response, and writing a database record all at once — and handle each result as it arrives.
This model is a natural fit for this system. Almost everything it does involves waiting on something external: an HTTP download, an API call, a disk read. A traditional synchronous language would sit idle between each step. Node progresses on whichever task is ready next, making it highly efficient for workloads that are I/O-bound rather than CPU-bound.
Express — The Web Framework
Express is a minimal web framework for Node.js. It gives the application a structured way to define HTTP routes and handle requests and responses. In this system, Express does two things: it defines the /process route that Enfocus Switch sends orders to, and it automatically parses the incoming JSON body so the rest of the code works with a plain JavaScript object rather than a raw text stream.
Without Express, the same functionality could be built using Node's raw http module, but routing, body parsing, and error handling would all have to be written from scratch. Express provides these as ready-built middleware wired in with a few lines of code. The name "middleware" in this project is a reference to the wider system role — Express is just one of several npm packages the application uses.
@anthropic-ai/sdk — The Claude API Client
The official Anthropic SDK for Node.js. It handles everything involved in calling the Claude API: attaching the API key on every request, serialising the message payload into the exact format the API expects, automatically retrying on transient network failures, and parsing the response back into a usable JavaScript object.
The SDK also provides built-in support for two features this system relies on. Tool use is the mechanism by which Claude can signal mid-generation that it wants to trigger a web search, receive the results back, and then continue writing — rather than producing a complete response in one shot. Prompt caching allows specific parts of the message (the static system prompt) to be marked for caching on Anthropic's servers so they are not re-processed on every call, significantly reducing both cost and latency for repeat orders from the same branch.
Tesseract.js — Local OCR Engine
OCR (Optical Character Recognition) is the process of reading printed or handwritten text from an image file — turning a photo into extractable characters. Tesseract is Google's open-source OCR engine, originally written in C++. Tesseract.js is a WebAssembly (WASM) port of it: the same engine compiled to a format that runs natively inside a Node.js process with no external binary, no installed software, and no service dependency.
When a customer uploads a photo of a handwritten note or a scanned document, Tesseract processes that image locally on the server and returns the extracted text as a string. That string is then included in the AI prompt alongside text extracted from Word and PDF attachments, so Claude can work with all the content regardless of how it was originally supplied.
mammoth — Word Document Parser
A .docx file is not a simple text file. It is a zip archive containing a bundle of XML files that together encode every paragraph, style, table, comment, and tracked change in the document. mammoth is a Node.js library that understands the Open XML specification Word uses. It opens the zip, navigates the XML structure, and returns clean plain text — handling edge cases like tracked changes, nested tables, and embedded objects that would cause a naive extraction to produce garbled output.
The result is a clean string that can be included directly in the Claude prompt with no further processing. mammoth also supports extracting rich HTML if formatting needs to be preserved, though this system uses plain text extraction.
pdf-parse — PDF Text Extractor
PDF files can contain text in two distinct ways. A text layer PDF is one where the content was typed digitally — in Word, a design tool, or a form — and the text is stored as actual characters inside the PDF. A scanned PDF is one where a physical page was photographed or scanned and saved as a PDF; the page is stored as an image, not as text, so there are no characters to extract.
pdf-parse reads the text layer. If the PDF has one, it extracts cleanly and quickly. If the PDF is a scanned image, pdf-parse returns an empty string — and that file would need to pass through the OCR image pipeline instead. Funeral directors typically supply digitally-created PDFs (exported from Word or booking systems), so the text layer is almost always present.
fast-xml-parser — XML Validator
fast-xml-parser is a high-performance XML parsing library for Node.js. In this system it is used exclusively for validation: before the populated XML is returned to Switch, the middleware runs it through XMLValidator.validate(). If the XML is well-formed — every tag opened and closed, no illegal characters, valid encoding — it passes through unchanged. If it is malformed for any reason (an unclosed tag, a stray ampersand, a broken UTF-8 sequence), the validator throws an error with the exact fault location (line and column number), and the middleware returns HTTP 422 to Switch instead of passing the broken data downstream.
This step exists because InDesign Server cannot gracefully handle malformed XML — it would either silently fail or produce a corrupted output file with no obvious indication of what went wrong. Catching the error at the middleware level means Switch can route the job to a manual review path and the designer is never presented with a broken file.
SQLite + PocketBase — Audit Database
SQLite is a file-based relational database: the entire database is stored in a single file on disk. There is no separate database server to install, configure, or connect to — the application reads and writes directly to the file using standard SQL. It is the most widely deployed database engine in the world (used in every Android and iOS device), and for a write-heavy audit log on a single server it is fast, reliable, and requires no maintenance.
PocketBase is an open-source backend built on top of SQLite. It adds a REST API (which the middleware uses to write job records), an admin dashboard accessible at /_/ (which is the "Database" interface visible in the docs dashboard), collection management, and simple authentication. The middleware writes to PocketBase's API on localhost; the admin UI provides a browser-based interface for viewing, filtering, and editing those records.
PM2 — Process Manager
PM2 is a production process manager for Node.js. It solves a fundamental operational problem: if the Node application crashes, or the server is rebooted for maintenance, the application simply stops running. PM2 wraps each process and monitors it continuously: if it exits unexpectedly, PM2 restarts it automatically. It also registers processes to start on system boot, so the middleware and database come back online after a reboot without any manual SSH session or intervention.
In this deployment, PM2 manages two processes: middleware (the Express application) and middleware-pb (the PocketBase database server). Both appear in pm2 list, and their logs are streamed in real time via pm2 logs.
| Command | What it does |
|---|---|
pm2 list | Show all managed processes, their status, restart count, and uptime. |
pm2 logs middleware | Stream live console output from the Express middleware. |
pm2 logs middleware-pb | Stream live console output from the PocketBase database server. |
pm2 restart middleware | Restart the Express app (e.g. after a code change to index.js). |
pm2 stop middleware-pb | Stop the database process (needed before wiping pb_data). |
pm2 describe middleware-pb | Show the full process config including working directory and script path. |
dotenv — Environment Variable Loader
dotenv is a small utility that reads a .env file from the project root at startup and loads its key-value pairs into process.env, making them available to the application throughout its lifetime. Environment variables are the standard way to store configuration that should not be hardcoded in source files — particularly secrets like API keys.
The .env file is excluded from version control via .gitignore, so the Anthropic API key never enters the repository or any commit history. On the production server, the file lives at /var/www/middleware.justdigital.co.uk/.env and contains a single line: ANTHROPIC_API_KEY=sk-ant-.... The application reads it on startup via require('dotenv').config().
Enfocus Switch — Workflow Router
Enfocus Switch is a print workflow automation platform that runs on a Windows machine and monitors configured data sources for new jobs. When a new booking is detected on the Ultimate platform, Switch executes a defined workflow: it packages the order metadata as a JSON payload and sends it as an HTTP POST to the middleware, then waits for the response XML. Once the response arrives, Switch downloads any linked layout assets and deposits the XML and image files into the Adobe InDesign Server hot folder.
Switch is the only integration point between the booking platform and the middleware. It handles both sides of the handshake: triggering the middleware when work arrives, and acting on the result when the middleware responds. The middleware itself is stateless — it processes one request and returns one response, with Switch managing the broader job lifecycle, error routing, and file delivery.
Adobe InDesign Server — Template Renderer
Adobe InDesign Server is a headless (no user interface) version of Adobe InDesign that runs as a background service and processes files programmatically. It watches a hot folder for incoming XML files. When Switch deposits the validated XML into that folder, InDesign Server detects it, opens the corresponding .indd template, and maps each XML tag value into the matching placeholder in the template — populating every text frame automatically. It then packages the completed InDesign file and saves it to a location the design team can access.
The link between the XML tags the middleware produces (e.g. <NameOfDeceased>, <Page3>) and the InDesign template placeholders is defined inside the template itself using InDesign's data merge or tagged text system. The middleware's responsibility ends at valid XML; InDesign Server handles all visual layout, typesetting, and file packaging from that point forward.
Extraction & OCR Engine
All attachment processing runs locally on the host server, with no external billing, maximum privacy, and client files never leaving the machine.
mammoth package to parse .docx binaries and extract clean plain text. Handles standard Word formatting reliably.pdf-parse to extract the text layer from digital PDFs. Note: scanned/image PDFs contain no text layer and will not extract; those would need OCR via the image pipeline..jpg, .png, .webp) for text. Zero cost, no external API calls, and images never leave the server.The heuristic checks: minimum word count (>6 words), minimum alpha-character ratio (>40%), absence of EXIF/metadata patterns (ISO, aperture, GPS coordinates, etc.), absence of Lorem Ipsum text, and presence of positive signals (service keywords like "hymn", "prayer", "committal"; instruction keywords like "please", "include", "page"; multi-line structured content).
- Retained: Running orders, song/hymn titles, poems, tributes, layout instructions.
- Discarded: Camera metadata, copyright notices, logo labels, gibberish noise, short random strings.
- Modify
performOcrOnImageinindex.jsto encode the image as Base64 and send it directly to the Anthropic Messages API with a vision-capable model. - Claude Vision handles cursive, corrections, arrows, and margin notes with significantly higher accuracy than Tesseract.
- Trade-off: adds API cost (~1–3p per image) and transmits customer images to Anthropic's servers.
Tesseract vs. Claude Vision Comparison
| Feature | Local Tesseract.js (Current) | Claude Vision (Upgrade Option) |
|---|---|---|
| Cost | Free ($0) | ~1–3p per image |
| Handwriting accuracy | Poor for cursive / messy writing | Exceptional; handles cursive, arrows, margin notes |
| Typed text accuracy | Good for standard printed fonts | Excellent |
| Speed | Fast, immediate local execution | Slower; network round-trip with image upload |
| Data privacy | Maximum; images never leave server | Standard; images sent to Anthropic's API |
Dynamic Rules Engine
A file-based rules system allows formatting instructions to be updated without code changes or server restarts. All files are read from disk on every request.
System Prompt Concatenation Structure
[SYSTEM PROMPT]: cached per arranger; cache hit on every repeat order from same branch
──────────────────────────────────────────────────────────────────────
Rules 1–8: Base persona, XML structure, priority hierarchy, (inline in index.js)
library lookup behaviour, copyright safety,
headers-only mode
9. Global style rules (master/master.txt)
10. Branch-specific rules (Branch/<arranger_id>.txt)
11. Consensus layout model (Branch/<arranger_id>_consensus-model.json)
──────────────────────────────────────────────────────────────────────
[USER MESSAGE]: dynamic per order; never cached
──────────────────────────────────────────────────────────────────────
XML Template: contents of the template file resolved from FinalTemplate.csv
Order Data: {deceased_name}, {dates}, {arranger_id}, etc. (compact JSON)
Attachments: extracted text from all downloaded DOCX / PDF / OCR files
──────────────────────────────────────────────────────────────────────
cache_control: { type: "ephemeral" }. Since the system prompt content is static per branch (same master rules + same branch rules + same consensus model), every repeat order from the same branch hits the cache.
- Cache read cost: $0.08/M tokens, approximately 10x cheaper than normal input.
- Cache threshold: 2,048 tokens minimum for Haiku 4.5; 1,024 for Sonnet 4.6.
- Cache invalidation: Editing any rule file updates the system prompt prefix and causes a single cache miss on the next job, then re-caches automatically.
Consensus Layout Model
An optional JSON file per branch derived from historical order data. It describes the typical page-by-page structure for that arranger's booklets, giving Claude a statistically-informed fallback layout when the customer hasn't specified a running order.
/Branch/<arranger_id>_consensus-model.json
If the file does not exist for a given arranger, the middleware silently skips it.
- page: Page number (2-8). Page 8 maps to
<BackPageMessage>. - elements: Typical content types (e.g. Hymn, Eulogy, Exit Music).
- metrics.avgWords: Historical average word count.
- stability: Consistency across orders (0-100%).
- confidence: Model confidence (0-100%).
How It Is Passed to Claude
Branch Consensus Page Layout Model (Arranger ID 9):
Use as a structural layout guide ONLY if the customer has not specified their own running order.
Typical layout for this branch:
- Page 2 (stability: 100%, confidence: 100%): typical → Order Of Service, Entrance Music, Words Of Welcome | ~64 words
- Page 3 (stability: 100%, confidence: 100%): typical → Order Of Service, Hymn, Entry Music | ~98 words
- Page 4 (stability: 96%, confidence: 100%): typical → Eulogy, Poem, Tribute | ~106 words
- Page 5 (stability: 100%, confidence: 96%): typical → Poem, Hymn, Eulogy | ~100 words
- Page 6 (stability: 91%, confidence: 98%): typical → Poem, Exit Music, The Lord's Prayer | ~93 words
- Page 7 (stability: 89%, confidence: 100%): typical → Committal, Exit Music, Closing Words | ~53 words
- Back Page (stability: 93%, confidence: 90%): typical → Refreshments, Thank You, Charity | ~97 words
Library Lookup & Copyright Handling
When Claude identifies a hymn, poem, or prayer referenced only by title, it triggers the web_search tool. The middleware intercepts this call and checks the Content Library database. If a match is found, a {{LIB:N}} token is returned to Claude and the full text is injected into the XML after generation — no external network request is made. If nothing matches, the middleware returns a "title only" instruction and Claude places the title in the field as-is; no fallback search of any kind is attempted.
const rec = await findLibraryRecord(q);
if (rec) {
// Register a token; body text is injected server-side after generation
const token = `{{LIB:${libraryTokens.nextIndex++}}}`;
libraryTokens.map[...] = editorToText(rec.body);
parts.push(`Found in library — place token ${token} where the body text belongs.`);
} else {
// No match: instruct Claude to use the title only
parts.push(`Not found in the internal content library. Populate the field with the title only.`);
}
Copyright Decision Logic
| Content type | System behaviour | XML output |
|---|---|---|
| Found in content library Any hymn, prayer, poem, or reading stored in the internal database |
Middleware returns a {{LIB:N}} token to Claude. Token is expanded to the full body text after generation. No external request made. |
Full text populated. |
| Not in library (any type) Title referenced but no matching library record |
Middleware instructs Claude to use the title only. No web search, no AI-generated text. | Title only inserted; designer completes the body text. |
| Modern / commercial Post-1930 pop, rock, R&B, hip-hop |
No library lookup. Placeholder inserted directly; note added flagging it as a potential data entry error. | [Lyrics of "Title" by Artist. Please insert manually due to copyright.] |
Content Library
An internal database of hymns, prayers, psalms, poems, and readings stored in the local SQLite database. When Claude triggers the web_search tool for an unknown title, the middleware checks this library. If a match is found, a {{LIB:N}} reference token is returned to Claude and the full body text is injected into the XML after generation — no network call, no cost, no latency. If nothing matches, Claude is instructed to use the title only; no external search is attempted.
How the Lookup Works
The middleware strips filler words from the AI's search query (e.g. "lyrics", "full text", "hymn", "prayer") to extract a clean title, then scores every record in the library using a Sørensen–Dice similarity match against the title and also_known_as fields (minimum 0.6 relevance score). If multiple variants of the same piece score equally, the middleware checks whether the AI's query mentions a variant name (e.g. "Catholic", "Welsh", "Traditional Anglican"); if so, that variant is returned. Otherwise the record marked is_default is used, falling back to the first result. When a match is found, a short {{LIB:N}} token is registered and returned to Claude; the body text is stored server-side and substituted into the final XML after Claude's response. If nothing matches, the middleware returns a "title only" instruction and no further lookup is made.
# Library hit — single version, no web request made
[Content Library] Hit: "The Lord's Prayer"
# Library hit — specific variant matched from query
[Content Library] Hit: "The Lord's Prayer" — Traditional Anglican
# Library hit — no variant specified, default returned
[Content Library] Hit: "The Lord's Prayer" — Catholic (is_default)
# Miss — title-only instruction returned to Claude
[Content Library] No hit for "Candle in the Wind lyrics", returning title-only instruction
Database Schema
| Field | Type | Required | Description |
|---|---|---|---|
title | Rich Text | Yes | Primary name of the piece. Supports italic/bold for stylised titles. Used as the main search key. E.g. The Lord's Prayer, Abide With Me. |
type | Select | No | Category, chosen from a fixed list: hymn, prayer, psalm, poem, reading. Returned alongside the content as context for Claude. |
body | Rich Text | Yes | The full text, lyrics, or content. Supports bold and italic formatting which is preserved in the XML output for InDesign. |
author | Rich Text | No | Composer, poet, or author if known. Supports italic for traditional attribution formatting. Included in the response prefix sent to Claude. |
variant | Text | No | Version label for pieces that exist in multiple forms. E.g. Traditional Anglican, Catholic, Modern Ecumenical, Scottish Reformed, Welsh, Scottish Gaelic, Irish. When the AI's query mentions this label, this record is selected over other variants. |
is_default | Bool | No | When multiple variants exist for the same title, mark one record as the default. This record is returned when the order does not specify a particular version. Only one record per title should have this set to true. |
also_known_as | Text | No | Comma-separated alternative titles or common variations. E.g. Our Father, Our Father Who Art in Heaven. For native language variants, include the title in that language here so it is matched by language-specific searches. |
Storing Multiple Versions of the Same Piece
Create a separate record for each version. Give each the same title, set the variant field to describe the version, and mark one record is_default to act as the fallback when no version is specified. For native-language versions (e.g. Welsh, Scottish Gaelic, Irish), add the native-language title in also_known_as so it is found by language-specific searches. A branch rule for a Welsh arranger can explicitly instruct Claude to request the Welsh version.
| title | variant | is_default | also_known_as |
|---|---|---|---|
| The Lord's Prayer | Traditional Anglican | true | Our Father, Our Father Who Art in Heaven |
| The Lord's Prayer | Catholic | Our Father (Catholic) | |
| The Lord's Prayer | Modern Ecumenical | Our Father (Contemporary) | |
| The Lord's Prayer | Scottish Reformed | Our Father (Debts) | |
| The Lord's Prayer | Welsh | Gweddi'r Arglwydd |
Adding Content
Records are managed directly in the Database Admin UI at http://localhost:8105/_/. Navigate to the content_library collection and create a new record. There is no restart required; the library is queried live on every request.
SQLite Audit Trails
Every order processed is logged to the local SQLite database, providing a complete audit trail of which rules were applied, the full output XML, processing time, and any notes generated.
| Field | Type | Description |
|---|---|---|
id | Text (Auto) | 15-character unique record ID. |
input_data | JSON | Raw order payload from Switch plus extracted text fragments and metadata keys (_consensus_model_used, _master_instructions_used, etc.). |
output_xml | Text | The finalized, populated XML returned to Switch. |
status | Text | success / success (mock) / Not an OOS / error: invalid xml / error: <message> |
duration_ms | Number | Total processing time in milliseconds from ingestion to XML return. |
master_instructions_used | Text | Exact contents of master/master.txt applied to this request. |
branch_instructions_used | Text | Exact contents of the branch file applied to this request. |
notes | Text | Claude-generated operator notes: copyright placeholders, manual-insert warnings, flags for unusual content. |
Testing & Diagnostics
# Check all running PM2 processes
pm2 list
# View real-time logs for Node server and database
pm2 logs middleware
pm2 logs middleware-pb
# View database process state and directory
pm2 describe middleware-pb
# Wipe and recreate a fresh database instance
pm2 stop middleware-pb
rm -rf /var/www/middleware.justdigital.co.uk/pocketbase/pb_data
pm2 start middleware-pb
- Asset download failure: Skips that file, continues processing remaining attachments.
- OCR returns blank text: Logs a warning, continues without that image's content.
- Content library miss: Claude is instructed to use the title only; no external request is made and the field is populated with the title rather than left empty.
- No product name in payload: Early exit; logged as "Not an OOS", no AI call made.
- Claude returns malformed XML: Caught by validator; HTTP 422 returned, Switch routes to manual review.