Policy OCR shipped 2026-08-01 (5e9cb12) and was documented nowhere. It is
not in INSURANCE_FEATURES_SPEC.md because it did not come from that
meeting — it came out of building the utility statement OCR pipeline in
RECEIPT_CAPTURE_SPEC.md §2 and noticing the same shape fits carrier
policy PDFs. A reader had no way to find that lineage.
New docs/POLICY_OCR.md covers it end to end, with weight on the three
things that are not obvious from the statement side:
- **One PDF = one policy.** Statements arrive bundled one customer per
page, so there a page is a document. A GMX certificate is one policy
across two pages, so the pages are concatenated and the parser runs
once per file — which is why `pageNumber` is a file ordinal and
`storageKey` is the source PDF, not a page image.
- **The GMX certificate carries no premium at all** — it lives on a
separate recibo PDF. Hence the null-preserving confirm and the
double-gated ledger write.
- **OcrModule was extracted out of StatementsModule to make this
possible**, and that was blocking rather than cosmetic.
Cross-referenced from RECEIPT_CAPTURE_SPEC.md §2 (where it came from),
INSURANCE_FEATURES_SPEC.md (which never proposed it, and whose §4 carrier
API it partly overlaps), PLAN.md step 11, README and RESUME.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
248 lines
12 KiB
Markdown
248 lines
12 KiB
Markdown
# Insurance Policy OCR Capture
|
|
|
|
Reads an insurance policy PDF the office downloads from a carrier portal,
|
|
proposes the `Policy` row it should become, and lets staff confirm. Built
|
|
2026-08-01 (`5e9cb12`), live under `/polizas/captura`.
|
|
|
|
## Why this exists — it was not planned
|
|
|
|
This feature is **not in any spec**. It came out of building the utility
|
|
statement OCR intake in [`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md)
|
|
§2: once there was a working render → OCR → parse → match → review pipeline
|
|
for CFE/CESPT/Telnor receipts, it was obvious the same shape applies to the
|
|
*other* stack of paper this office keys in by hand every week — the carrier
|
|
policy PDFs behind every `Policy` row.
|
|
|
|
The two are the same job with a different document on the scanner. Keeping
|
|
that recognition cheap is the whole point of how it was built: the pipeline
|
|
was **reused, not copied**.
|
|
|
|
- `OcrModule` (`apps/api/src/ocr/ocr.module.ts`) was extracted out of
|
|
`StatementsModule` in this same commit, purely so `PolicyOcrModule` can
|
|
inject `OCR_PROVIDER` without dragging in the statement pipeline.
|
|
`StatementsModule` now imports it and binds nothing itself. That extraction
|
|
was **blocking**: without it the policy module could not resolve the
|
|
provider at all.
|
|
- The engine stays Tesseract behind the same swappable seam, so a managed
|
|
extraction API remains a one-line change in one file for both features.
|
|
- The intake screen is a *mode of* the existing policy-creation screen, the
|
|
same way OCR receipt capture is a mode of Captura — not a new menu entry.
|
|
|
|
## What ships
|
|
|
|
| Piece | Path |
|
|
|---|---|
|
|
| API module | `apps/api/src/policy-ocr/` (service, controller, DTOs, matcher, parser) |
|
|
| Shared OCR seam | `apps/api/src/ocr/ocr.module.ts` |
|
|
| Tables | `policy_ocr_batches`, `policy_ocr_documents` (`20260801000000_policy_ocr_intake`) |
|
|
| Web | `components/PolicyCaptura.tsx` (tab shell), `PolicyOcrIntake.tsx` (upload), `PolicyOcrReview.tsx` (review queue) |
|
|
| Abilities | `policy:ingest`, `policy:ocr-review` — both **STAFF** |
|
|
|
|
Abilities are STAFF for the same reason statement OCR is: nothing reaches the
|
|
books unconfirmed, and the review step is what makes machine capture safe at
|
|
that tier.
|
|
|
|
## The screen
|
|
|
|
`PolicyCaptura` is one screen with two ways in, mirroring `Captura.tsx`:
|
|
|
|
- `/polizas/nuevo` → **manual** tab (`PolicyForm`, every field by hand)
|
|
- `/polizas/captura` → **automática** tab (`PolicyOcrIntake`, drop a PDF)
|
|
- `/polizas/captura/[id]` → the batch review queue
|
|
|
|
Both modes end at the same place — a `Policy` row on a customer's file — so
|
|
they are modes of one screen rather than two menu entries. Either URL renders
|
|
the same component, so the tab toggle works from either entry point and old
|
|
bookmarks land on the right tab.
|
|
|
|
## Pipeline
|
|
|
|
```
|
|
upload PDF → store source → render pages → text layer? → parse → match → review → confirm
|
|
```
|
|
|
|
1. **Store the source.** `policy-ocr/{batchId}/source-N.pdf`, before anything
|
|
else touches it.
|
|
2. **Render + read.** Every page is rendered to
|
|
`policy-ocr/{batchId}/page-M.png`. Text-layer wins when the PDF has one
|
|
(cheap, exact); the rendered image is OCR'd only when it does not — the
|
|
same precedence rule as the statement pipeline. Carrier-portal PDFs are
|
|
usually born-digital, so most of the time no OCR runs at all.
|
|
3. **Parse.** Provider detected by brand signal first
|
|
(`GMX`, `Grupo Mexicano de Seguros`, `gmx.com.mx`,
|
|
`JUNTOS EL RIESGO ES MENOR`), layout patterns only as fallback — the same
|
|
ordering rule the statement parser needed.
|
|
4. **Match.** Against `Policy.policyNumber`.
|
|
5. **Review + confirm.** Nothing is written to `Policy` until a human
|
|
confirms.
|
|
|
|
### One PDF = one policy
|
|
|
|
This is the sharpest difference from statement OCR, and it inverts that
|
|
feature's core assumption.
|
|
|
|
Utility statements arrive **bundled, one customer per page** — so there, one
|
|
page is one document and the parser runs per page. A policy PDF is the
|
|
opposite: the GMX certificate is a 2-page document where page 1 carries the
|
|
contract header and page 2 carries the per-coverage table, and **both pages
|
|
describe the same policy**. So the pipeline concatenates every page's text
|
|
(`\n\n` between pages, which also keeps `ocrRawText` readable for debugging)
|
|
and runs the parser and the matcher exactly **once per file**.
|
|
|
|
Consequences worth knowing before touching this code:
|
|
|
|
- `PolicyOcrDocument.pageNumber` is repurposed as the **file ordinal within
|
|
the batch** (1, 2, 3…), not a page index. The
|
|
`(batchId, pageNumber)` unique constraint still holds, and one batch still
|
|
carries many policies — one per uploaded file.
|
|
- Parser regexes are anchored across the whole concatenated text (`^From$`,
|
|
`^Currency\s+…`), which is why the page-boundary blank line matters.
|
|
- `ocrConfidence` on the row is the **mean** across the file's pages.
|
|
- A file that fails to parse produces exactly one `OCR_FAILED` row — the right
|
|
granularity, and the page PNGs stay on disk for a re-run after a parser fix.
|
|
|
|
### `storageKey` is the source PDF, not a page image
|
|
|
|
`PolicyOcrDocument.storageKey` points at `source-N.pdf`. The review screen
|
|
embeds that file directly, so the reviewer looks at the **exact artifact the
|
|
office received** and gets the browser's native PDF scrolling, zoom and text
|
|
selection for free. Rendered PNGs are still written for future re-OCR or an
|
|
image-based audit, but nothing points at them as the document's identity.
|
|
|
|
(The statement side does the opposite — there `storageKey` is the page image,
|
|
because a page *is* the document.)
|
|
|
|
## Matching: policy number only, never the insured name
|
|
|
|
`PolicyMatcherService` matches on `Policy.policyNumber` and nothing else.
|
|
|
|
The certificate's "Insured" line is the account's registrant, which drifts
|
|
from the customer the office actually holds the file under — the same finding
|
|
the statement matcher is built around (a CESPT receipt reading
|
|
`ARNAIZ ROSAS ELSA AURORA` for a customer this office holds as `CATT, RANDY`).
|
|
Names are shown to the reviewer as a sanity check and never feed matching.
|
|
|
|
| Rows on `policyNumber` | Result |
|
|
|---|---|
|
|
| exactly 1 | `MATCHED`, confident — the only unambiguous hit |
|
|
| 0 | new policy: review offers a customer picker, confirm **creates** the row |
|
|
| >1 | surfaced as candidates, human picks |
|
|
|
|
More than one hit is never auto-resolved. Duplicate policy numbers across
|
|
customers do occur (one group policy bound by two related parties), and
|
|
picking arbitrarily would silently book the wrong coverage against the wrong
|
|
person.
|
|
|
|
## What the parser reads, and the field it cannot
|
|
|
|
`ParsedPolicy` fields are all nullable on purpose: each carrier prints a
|
|
different subset, and the matcher and review queue both work better with
|
|
"field was read" vs "field was not" than with a guess.
|
|
|
|
Read from the GMX certificate: policy number, insured name, additional
|
|
insured, broker (→ `Policy.agentName`), legal address, ZIP, `policyFrom` /
|
|
`policyTo` / `policyDate`, currency, premium-payment cadence, and the full
|
|
per-coverage table (risk, insured amount, deductible, loss participation)
|
|
preserved verbatim.
|
|
|
|
> **The GMX certificate carries no premium.** Not "sometimes missing" — the
|
|
> document does not have the figure. It lives on GMX's **separate `recibo`
|
|
> PDF**. The parser leaves `netPremium` / `policyFee` / `brokerFee` / `total`
|
|
> null and pushes a note onto the row —
|
|
> *"esta página no trae prima; revisar el recibo de GMX por separado"* — so
|
|
> the reviewer sees why the field is empty rather than assuming a read
|
|
> failure.
|
|
|
|
This is also why confirm never overwrites an existing `Policy.netPremium`
|
|
with null: the certificate not carrying a premium is not evidence that the
|
|
premium is gone.
|
|
|
|
Deductible and loss participation are stored as **strings** (`"5%"`, `"20%"`,
|
|
`"USD 1,000"`) — they are printed as a mix of percentages, currency amounts
|
|
and free text, and normalising them would lose the distinction.
|
|
|
|
## Confirm: what actually gets written
|
|
|
|
Per confirmed document, in order:
|
|
|
|
1. **The `Policy` row** — updated if a policy was matched, created under the
|
|
picked customer if not. Only non-null `extracted*` fields are written; null
|
|
never overwrites existing data.
|
|
2. **A `PolicyDocument`** — the source PDF is streamed into the policy's
|
|
storage namespace and attached, so the paperwork stays with the policy.
|
|
3. **Optionally a `Transaction`** — `INSURANCE` domain, negative amount
|
|
(a charge), `captureSource: "OCR"`, `captureRef` = the document id.
|
|
|
|
The ledger write is **opt-in twice over**: staff must tick `postPremium`
|
|
*and* a premium must have parsed to a positive number. Without that gate the
|
|
premium-less certificate above would silently book a $0 charge on every
|
|
confirm.
|
|
|
|
`createdPolicyId` and `postedTransactionId` are unique columns on the
|
|
document row, so a double-confirm cannot re-apply — and a `POSTED` document
|
|
is refused outright.
|
|
|
|
Discarding a batch is refused once any page is `POSTED`: a partly-applied
|
|
batch has already written `Policy` (and possibly `Transaction`) rows, and
|
|
hiding the paperwork behind a "discarded" label would leave those rows
|
|
unexplained. Reject the remaining pages individually instead.
|
|
|
|
## API surface
|
|
|
|
| Method | Route | Ability |
|
|
|---|---|---|
|
|
| `GET` | `/policy-ocr/status` (is OCR + storage available) | authenticated |
|
|
| `GET` | `/policy-ocr/batches`, `/batches/:id`, `/batches/:id/documents` | authenticated |
|
|
| `GET` | `/policy-ocr/documents/:id/page` (streams the source PDF) | authenticated |
|
|
| `POST` | `/policy-ocr/batches` (upload) | `policy:ingest` |
|
|
| `PATCH` | `/policy-ocr/documents/:id` (edit the extracted fields) | `policy:ocr-review` |
|
|
| `POST` | `/policy-ocr/documents/:id/reject` | `policy:ocr-review` |
|
|
| `POST` | `/policy-ocr/batches/:id/discard` | `policy:ocr-review` |
|
|
| `POST` | `/policy-ocr/batches/:id/confirm` | `policy:ocr-review` |
|
|
|
|
## Requirements
|
|
|
|
Same as statement OCR: object storage (`S3_ENDPOINT` + credentials) for the
|
|
source PDFs and page images, and `tesseract-ocr` / `tesseract-ocr-data-spa` /
|
|
`poppler-utils` in the API image. `GET /policy-ocr/status` reports both; if
|
|
either is missing the feature reports itself unavailable and only this
|
|
feature is disabled.
|
|
|
|
## Tests
|
|
|
|
`apps/api/src/policy-ocr/parsers/policy-parser.spec.ts` — 8 cases, all
|
|
against verbatim text extracted from one real document,
|
|
`HC_Folio_000767_Traduccion.pdf`: provider detection from the wordmark and
|
|
from the footer URL, the header fields, every coverage row off the second
|
|
page, the deductible/loss-participation strings, the missing-premium note,
|
|
the broker line with the agent-number parens absent, and a page with no GMX
|
|
signal at all (which must yield no provider rather than a bad guess).
|
|
|
|
## Not built
|
|
|
|
- **Only GMX.** The dispatcher (`detectPolicyProvider`) is a table of
|
|
`[provider, pattern]` pairs plus a `parsers` map, so adding ANA or Qualitas
|
|
is a parser function and two entries — but no other carrier's layout has
|
|
been seen yet, and guessing at one produces a parser nobody can verify.
|
|
- **The `recibo` PDF.** Reading the premium off GMX's separate receipt
|
|
document, and pairing it to the certificate it belongs to, is the obvious
|
|
next piece. It is what would let `postPremium` stop being a manual tick.
|
|
- **Renewals from OCR.** A re-issued policy arrives as a new certificate with
|
|
the same number; confirm updates the existing row rather than versioning
|
|
it. Nothing tracks "this is the 2027 issue of that policy".
|
|
|
|
## Related
|
|
|
|
- [`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md) §2 — the utility
|
|
statement pipeline this was lifted from, and the origin of three rules the
|
|
policy parser applies: detect the provider by brand before layout, only
|
|
ever apply the Tesseract digit-confusion map (`O→0`, `S→5`, `B→8`, …) to
|
|
fields known to be digits, and parse amounts by separator *position* rather
|
|
than assuming `,` is thousands.
|
|
|
|
Those last two are **duplicated on purpose**, not imported: the module is
|
|
kept self-contained, since sharing a helper would couple two unrelated
|
|
domains through it. If you fix a bug in one, check the other.
|
|
- [`INSURANCE_FEATURES_SPEC.md`](INSURANCE_FEATURES_SPEC.md) — the four
|
|
insurance features that *were* planned. This is not one of them.
|