feat(policy-ocr): read A.N.A. Seguros' two policy faces
A.N.A. is the Rosarito office's tourist auto book and the second carrier
the policy OCR pipeline reads. It ships two unrelated faces, and the split
is different from GMX's: GMX ships two documents about one policy, A.N.A.
ships two products.
AUTOMOBILE (SPECIAL POLICY FOR TOURISTS) insures a car; vehicle table,
9 numbered sections, one
LIMIT OF LIABILITY column
DRIVER'S POLICY (the office: "licencia") insures up to 5 named drivers;
no vehicle at all, 6 unnumbered
sections in a different order,
SUM INSURED + PREMIUM columns
The four automobile products the office sells (amplia / responsabilidad
civil, annual / by-the-day) are the same layout with different numbers, so
they get one parser rather than four.
These are born-digital portal PDFs, so pdftotext -layout returns exact
columns and the driver's-policy parser uses that: its two value columns
print the same shape (100,000.00 usd. / 18.70 usd.) with no per-row label,
so horizontal position is the only thing separating them. The split comes
from the header's own offsets, not a constant, because they shift between
products; when it can't be read every amount is reported as a sum insured
and the reviewer is told, rather than half the premiums being filed as
coverage limits.
Three things the layout will punish a naive read for:
- Each PDF prints its face two or three times (ORIGINAL, AGENT COPY, then
a receipt and three travel cards) and the pipeline concatenates every
page before parsing. The coverage walk is bounded to the first copy and
the driver list to the first POLICY HOLDER block. Unbounded, the licencia
returns the same person three times, which reads as a three-driver policy
rather than as a bug.
- The money row is read positionally off its header. An unused DISCOUNT
prints as a bare "-", so "find the six amounts" shifts every value one
column left on a discounted policy.
- Two five-digit numbers sit in the header band and only one is the agent
clave; the agent's street address is "BENITO JUAREZ 25 No.50 INT 38",
three lines above the No. cell holding the policy number.
Sections 6-8 print a PREMIUM where the others print a limit, so
ParsedCoverage gains an optional `premium` (GMX never fills it) and the
review table a column: $40 is what legal aid cost, not a $40 liability
limit. Exclusions follow the GMX rule and go in the risk label with a null
amount -- which matters more here, since a responsabilidad-civil policy
prints 0.00 for material damage and the two are identical on the page.
Also in this change:
- coveragePeriodDays is parsed and written. A.N.A. sells 3- and 4-day
policies; Policy.coveragePeriodDays defaults to 365, so a weekend policy
left at the default sits in the renewals window a year out. Derived from
the dates, cross-checked against the printed DAYS cell, disagreement
noted not resolved.
- Vehicles and named drivers are parsed, shown read-only in review, and
written as Vehicle / InsuredDriver rows on confirm, skipping any already
on the policy (VIN then plate; licence then name). The case that forces
the skip is confirming a renewal onto an existing policy. Nothing is ever
updated or deleted -- a changed plate lands as a second row for a human.
- Batch.provider is set from what the parsers actually claimed instead of
being hardcoded "GMX", so a mixed upload is labelled as mixed and the
header can never contradict its own documents. PolicyDocument.documentType
follows the same rule (was hardcoded GMX_POLICY).
- matchNote becomes TEXT. It was VARCHAR(191) and the note trail was sliced
to 190 chars, which cut the tail notes -- the "could not read X" ones.
- The policy detail page renders an array coveragesJson as a table. Both
shapes have always been possible there, but the object renderer was the
only one, so an OCR-confirmed policy showed a row per array index labelled
"0", "1", "2" with [object Object] as the value. ANA makes that routine.
GMX is untouched behaviourally; its two parsers now spread a shared empty
base instead of listing every null field. 29 new parser cases against
verbatim pdftotext output of three real ANA PDFs, 53 in the suite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
-- ANA Seguros policy OCR: the vehicle/driver tables and the printed term in
|
||||
-- days that ANA's tourist book carries and GMX's property book does not.
|
||||
ALTER TABLE `policy_ocr_documents`
|
||||
ADD COLUMN `extractedCoveragePeriodDays` INTEGER NULL,
|
||||
ADD COLUMN `extractedVehiclesJson` JSON NULL,
|
||||
ADD COLUMN `extractedDriversJson` JSON NULL;
|
||||
|
||||
-- The parser note trail outgrew VARCHAR(191): a nine-section ANA policy runs
|
||||
-- past it routinely, and the notes that got cut were the tail ones — the
|
||||
-- "could not read X" warnings the reviewer most needs.
|
||||
ALTER TABLE `policy_ocr_documents`
|
||||
MODIFY COLUMN `matchNote` TEXT NULL;
|
||||
@@ -379,8 +379,11 @@ model PolicyDocument {
|
||||
/// source PDF and optionally writes a premium Transaction.
|
||||
model PolicyOcrBatch {
|
||||
id String @id @default(uuid())
|
||||
/// Which insurance provider portal the batch came from. "GMX" today;
|
||||
/// future providers (AXA, GNP, …) extend the parser, not this table.
|
||||
/// Which insurance provider portal the batch came from — "GMX", "ANA", or
|
||||
/// "GMX + ANA" when one upload mixed them. Set by the pipeline from what
|
||||
/// the parsers actually claimed, not asked of the uploader, so it can
|
||||
/// never contradict the documents. Future providers extend the parser,
|
||||
/// not this table.
|
||||
provider String @default("GMX")
|
||||
status PolicyOcrBatchStatus @default(UPLOADED)
|
||||
uploadedById String
|
||||
@@ -442,10 +445,22 @@ model PolicyOcrDocument {
|
||||
extractedBrokerFee Decimal? @db.Decimal(12, 2)
|
||||
extractedTotal Decimal? @db.Decimal(12, 2)
|
||||
/// Per-coverage rows from the GMX "Material damages" / "Additional risk"
|
||||
/// tables — preserved verbatim so a missing premium receipt still leaves
|
||||
/// the coverages auditable.
|
||||
/// tables and ANA's numbered risk sections — preserved verbatim so a
|
||||
/// missing premium receipt still leaves the coverages auditable.
|
||||
extractedCoveragesJson Json?
|
||||
extractedPremiumPayment String?
|
||||
/// Printed term length. ANA sells 3- and 4-day tourist policies, so
|
||||
/// leaving `Policy.coveragePeriodDays` at its 365 default would overstate
|
||||
/// a weekend policy by a year.
|
||||
extractedCoveragePeriodDays Int?
|
||||
/// `ParsedVehicle[]` off ANA's ITEM/YEAR/MAKE/BODY/SERIAL/PLATES table.
|
||||
/// Written to `Vehicle` rows on confirm; kept here so the review screen
|
||||
/// shows what was read before anything is applied.
|
||||
extractedVehiclesJson Json?
|
||||
/// `ParsedDriver[]` — the insured on ANA's automobile face, the numbered
|
||||
/// POLICY HOLDER list on its driver's policy. Written to `InsuredDriver`
|
||||
/// rows on confirm.
|
||||
extractedDriversJson Json?
|
||||
|
||||
// Match by `Policy.policyNumber` → existing Policy / Customer.
|
||||
matchedPolicyId String?
|
||||
@@ -456,7 +471,11 @@ model PolicyOcrDocument {
|
||||
/// normal; >1 means the policy number is shared across customers and a
|
||||
/// human must pick.
|
||||
matchCandidates Json?
|
||||
matchNote String?
|
||||
/// Text, not VARCHAR(191): this carries the parser's whole note trail, and
|
||||
/// a multi-section ANA policy runs past 191 characters routinely. Silently
|
||||
/// truncating it drops the tail notes, which are the ones that say what
|
||||
/// could NOT be read.
|
||||
matchNote String? @db.Text
|
||||
|
||||
reviewedById String?
|
||||
reviewedBy User? @relation("PolicyOcrDocumentReviewer", fields: [reviewedById], references: [id])
|
||||
|
||||
Reference in New Issue
Block a user