Files
jorgecuadros-platform/docs/POLICY_OCR.md
T
rmancinasandClaude Opus 5 872a661051 docs: document policy OCR capture, the feature no spec proposed
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>
2026-08-02 12:56:25 -07:00

12 KiB

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 §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/nuevomanual tab (PolicyForm, every field by hand)
  • /polizas/capturaautomá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 TransactionINSURANCE 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".
  • 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 — the four insurance features that were planned. This is not one of them.