feat(polizas): OCR capture for insurance policy PDFs
Mirrors the utility statement intake on the insurance side: a policy_ocr batch/document pair of tables, a GMX parser, a matcher keyed on Policy.policyNumber, and a "Captura" screen under /polizas that proposes policy -> customer for staff to confirm. Lifts the OCR seam out of StatementsModule into its own OcrModule so PolicyOcrModule can inject OCR_PROVIDER without taking on the rest of the statement pipeline; StatementsModule now imports it and binds nothing itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
import type { OcrPage } from "../../statements/ocr/ocr.provider";
|
||||
|
||||
/**
|
||||
* What one parsed policy page yields. All fields are nullable because each
|
||||
* provider prints a different subset (GMX's certificate has no premium
|
||||
* breakdown, only insured amounts; GMX's receipt page would carry the
|
||||
* premium), and the matcher + the review queue both work better with
|
||||
* "field was read" vs "field was not" rather than guessing.
|
||||
*/
|
||||
export interface ParsedPolicy {
|
||||
/** "GMX" today; the dispatcher lives on `detectProvider`. */
|
||||
provider: string;
|
||||
policyNumber: string | null;
|
||||
insuredName: string | null;
|
||||
additionalInsured: string | null;
|
||||
/** The "Broker" line on GMX — mapped onto `Policy.agentName`. */
|
||||
agentName: string | null;
|
||||
legalAddress: string | null;
|
||||
zip: string | null;
|
||||
policyFrom: Date | null;
|
||||
policyTo: Date | null;
|
||||
/** Signature/issue date — `Policy.policyDate`. */
|
||||
policyDate: Date | null;
|
||||
/** "MXN" | "USD" | …, derived from the printed currency word. */
|
||||
currency: string | null;
|
||||
netPremium: number | null;
|
||||
policyFee: number | null;
|
||||
brokerFee: number | null;
|
||||
total: number | null;
|
||||
/** "CONTADO" / "MENSUAL" / … — premium-payment cadence text. */
|
||||
premiumPayment: string | null;
|
||||
/**
|
||||
* GMX prints per-coverage rows in a table: Building / Contents /
|
||||
* Earthquake / … with insured amount, deductible, loss participation.
|
||||
* Preserved verbatim so a missing premium receipt still leaves the
|
||||
* coverages auditable on the Policy row.
|
||||
*/
|
||||
coverages: ParsedCoverage[];
|
||||
/** Human-readable trail of what was read, surfaced in the review queue. */
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface ParsedCoverage {
|
||||
/** "Building", "Contents", "Debris removal Building", "Earthquake…". */
|
||||
risk: string;
|
||||
insuredAmount: number | null;
|
||||
deductible: string | null;
|
||||
lossParticipation: string | null;
|
||||
}
|
||||
|
||||
// --- shared helpers ---------------------------------------------------------
|
||||
|
||||
const DIGIT_CONFUSIONS: Record<string, string> = {
|
||||
O: "0", o: "0", D: "0", I: "1", l: "1", "|": "1", S: "5", B: "8",
|
||||
};
|
||||
|
||||
/**
|
||||
* Tesseract confuses these glyphs inside numeric runs with some regularity.
|
||||
* Same map and same caveat as the statement parser: ONLY apply to fields
|
||||
* known to be digits, never to free text.
|
||||
*/
|
||||
function toDigits(s: string | null | undefined): string {
|
||||
if (!s) return "";
|
||||
return s
|
||||
.split("")
|
||||
.map((c) => DIGIT_CONFUSIONS[c] ?? c)
|
||||
.join("")
|
||||
.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a printed amount, treating `,` and `.` by position rather than by
|
||||
* assumption. Same algorithm as the statement parser — kept here so the
|
||||
* policy module is self-contained, since importing from `../../statements`
|
||||
* would couple two unrelated domains through a helper.
|
||||
*/
|
||||
function money(s: string | null | undefined): number | null {
|
||||
if (!s) return null;
|
||||
const cleaned = s.replace(/[\s$]/g, "");
|
||||
|
||||
let m = cleaned.match(/^(\d{1,3}(?:[.,]\d{3})+)([.,]\d{1,2})?$/);
|
||||
if (m) {
|
||||
const whole = m[1].replace(/[.,]/g, "");
|
||||
const cents = m[2] ? m[2].slice(1) : "";
|
||||
return Number(cents ? `${whole}.${cents.padEnd(2, "0")}` : whole);
|
||||
}
|
||||
|
||||
m = cleaned.match(/^(\d+)[.,](\d{2})$/);
|
||||
if (m) return Number(`${m[1]}.${m[2]}`);
|
||||
|
||||
const n = Number(cleaned.replace(/[,.]/g, ""));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function firstMatch(text: string, patterns: RegExp[]): string | null {
|
||||
for (const p of patterns) {
|
||||
const m = text.match(p);
|
||||
if (m?.[1]) return m[1].trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const MONTHS: Record<string, number> = {
|
||||
ENE: 0, FEB: 1, MAR: 2, ABR: 3, MAY: 4, JUN: 5,
|
||||
JUL: 6, AGO: 7, SEP: 8, OCT: 9, NOV: 10, DIC: 11,
|
||||
};
|
||||
|
||||
/**
|
||||
* DD/MM/YYYY (GMX) and the dash-separated ISO variants. Two-digit years are
|
||||
* windowed: < 50 → 20YY, ≥ 50 → 19YY, matching what a 1950-2049 window
|
||||
* expects from a paper document.
|
||||
*/
|
||||
function parseDate(raw: string | null | undefined): Date | null {
|
||||
if (!raw) return null;
|
||||
const s = raw.trim();
|
||||
|
||||
let m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
if (m) return utc(+m[3], +m[2] - 1, +m[1]);
|
||||
|
||||
m = s.match(/^(\d{1,2})[-\s/]([A-Z]{3})[-\s/](\d{2,4})$/i);
|
||||
if (m && MONTHS[m[2].toUpperCase()] !== undefined) {
|
||||
const yr = +m[3];
|
||||
const y = m[3].length === 2 ? (yr < 50 ? 2000 + yr : 1900 + yr) : yr;
|
||||
return utc(y, MONTHS[m[2].toUpperCase()], +m[1]);
|
||||
}
|
||||
|
||||
m = s.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/);
|
||||
if (m) return utc(+m[1], +m[2] - 1, +m[3]);
|
||||
|
||||
// "July 23, 2026" — the signature date on the GMX certificate.
|
||||
m = s.match(/^([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})$/);
|
||||
if (m) {
|
||||
const MONTH_NAMES: Record<string, number> = {
|
||||
january: 0, february: 1, march: 2, april: 3, may: 4, june: 5,
|
||||
july: 6, august: 7, september: 8, october: 9, november: 10, december: 11,
|
||||
};
|
||||
const mo = MONTH_NAMES[m[1].toLowerCase()];
|
||||
if (mo !== undefined) return utc(+m[3], mo, +m[2]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function utc(y: number, mo: number, d: number): Date | null {
|
||||
const dt = new Date(Date.UTC(y, mo, d));
|
||||
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||
}
|
||||
|
||||
/** Map the printed currency word onto an ISO code. */
|
||||
function currencyCode(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
const s = raw.trim().toUpperCase();
|
||||
if (s.startsWith("PESO") || s === "MXN" || s.includes("NACIONAL")) return "MXN";
|
||||
if (s.startsWith("DOLAR") || s === "USD" || s.includes("DOLLAR")) return "USD";
|
||||
if (s === "EUR" || s.includes("EURO")) return "EUR";
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- provider detection -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Brand first, layout as a fallback. Same ordering rule as the statement
|
||||
* parser: a brand wordmark is the cheapest, most reliable discriminator, and
|
||||
* a layout rule that runs first can wrongly claim a page that happens to
|
||||
* carry the same shape string (the statement parser's lesson with CFE vs
|
||||
* GAS on "PERIODO FACTURADO").
|
||||
*/
|
||||
const BRAND: [string, RegExp][] = [
|
||||
["GMX", /\bGMX\b|Grupo\s*Mexicano\s*de\s*Seguros|gmx\.com\.mx|JUNTOS\s*EL\s*RIESGO\s*ES\s*MENOR/i],
|
||||
];
|
||||
|
||||
const LAYOUT: [string, RegExp][] = [
|
||||
["GMX", /Multiple\s*Policy|IMPUESTO\s*PREDIAL[\s\S]{0,80}EN\s*FECHA|Material\s*damages\s*Section/i],
|
||||
];
|
||||
|
||||
export function detectPolicyProvider(text: string): string | null {
|
||||
for (const group of [BRAND, LAYOUT]) {
|
||||
for (const [name, pattern] of group) {
|
||||
if (pattern.test(text)) return name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- parsers ----------------------------------------------------------------
|
||||
|
||||
const PARSERS: Record<string, (page: OcrPage) => ParsedPolicy> = {
|
||||
GMX: parseGmx,
|
||||
};
|
||||
|
||||
const EMPTY_COVERAGE: ParsedCoverage = {
|
||||
risk: "",
|
||||
insuredAmount: null,
|
||||
deductible: null,
|
||||
lossParticipation: null,
|
||||
};
|
||||
|
||||
export function parsePolicy(page: OcrPage): ParsedPolicy {
|
||||
const provider = detectPolicyProvider(page.text);
|
||||
if (!provider) {
|
||||
return {
|
||||
provider: "",
|
||||
policyNumber: null,
|
||||
insuredName: null,
|
||||
additionalInsured: null,
|
||||
agentName: null,
|
||||
legalAddress: null,
|
||||
zip: null,
|
||||
policyFrom: null,
|
||||
policyTo: null,
|
||||
policyDate: null,
|
||||
currency: null,
|
||||
netPremium: null,
|
||||
policyFee: null,
|
||||
brokerFee: null,
|
||||
total: null,
|
||||
premiumPayment: null,
|
||||
coverages: [],
|
||||
notes: ["no se reconoció el proveedor"],
|
||||
};
|
||||
}
|
||||
return PARSERS[provider](page);
|
||||
}
|
||||
|
||||
// --- GMX --------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GMX policy certificate layout (this is the translation PDF — the Spanish
|
||||
* version is the canonical source, but every GMX portal download is a
|
||||
* translation so the parser can rely on these English labels).
|
||||
*
|
||||
* Page 1 carries the contract header in a single boxed table:
|
||||
* Policy | Insured | Additional insured | Legal address | ZIP | Income Tax No.
|
||||
* Broker | Term | From | To | Currency | Premium payment
|
||||
* followed by an "Agreed clauses" block, the signature date, and the GMX
|
||||
* letterhead.
|
||||
*
|
||||
* Page 2 carries the per-coverage table (Risk / Insured Amount / Deductible /
|
||||
* Loss Participation) under "Material damages Section" and "ADDITIONAL RISK".
|
||||
*
|
||||
* Premium / total / fees are NOT on the certificate page — they live on
|
||||
* GMX's separate "recibo" PDF. The parser leaves them null and flags the
|
||||
* gap in `notes`; the matcher still proposes a Policy update from the
|
||||
* certificate alone, and the staff confirm step fills premium in by hand
|
||||
* or after a follow-up receipt upload.
|
||||
*/
|
||||
function parseGmx(page: OcrPage): ParsedPolicy {
|
||||
const text = page.text;
|
||||
const notes: string[] = [];
|
||||
|
||||
// ----- header table (page 1) --------------------------------------------
|
||||
// The Policy row repeats the number in a long run:
|
||||
// "Policy 007-037-07005947-0000-02 in accordance with the enclosed clauses…"
|
||||
// so taking the first token-shaped number is correct; the trailing prose
|
||||
// never looks like one. The dashes are part of the printed number — keep
|
||||
// them (don't run toDigits, which would flatten them).
|
||||
const policyNumber = firstMatch(text, [
|
||||
/\bPolicy\s+([0-9OIlSBD]{3,4}[-\s][0-9OIlSBD]{3}[-\s][0-9OIlSBD]{8}[-\s][0-9OIlSBD]{4}[-\s][0-9OIlSBD]{2})/i,
|
||||
/\bPolicy\s+([0-9OIlSBD][0-9OIlSBD\s-]{9,30})/,
|
||||
]);
|
||||
|
||||
// "Insured JON ASHLEY STRABALA" — label, then 1+ whitespace, then the name.
|
||||
// Names can carry accents (ÁVILA) or apostrophes (O'NEILL); the label is
|
||||
// always upper-case English on this layout, so case is reliable.
|
||||
const insuredName = labelValue(text, /^Insured\s+([A-ZÁÉÍÓÚÑ'][A-ZÁÉÍÓÚÑ '\-.]+)$/m);
|
||||
const additionalInsured = labelValue(text, /^Additional\s+insured\s+([A-ZÁÉÍÓÚÑ '\-.]+)$/m);
|
||||
|
||||
// Legal address is a single long line; the parser keeps it whole.
|
||||
const legalAddress = labelValue(text, /^Legal\s+address\s+(.+)$/m);
|
||||
const zip = labelValue(text, /^ZIP\s+(\d{4,6})\b/m);
|
||||
if (!zip && legalAddress) {
|
||||
// Last resort: zip often appears at the tail of the address run too
|
||||
// ("…C.P. 22550"). Cheap regex, no false-positive cost on this layout.
|
||||
const m = legalAddress.match(/\b(\d{5})\b/);
|
||||
if (m) notes.push(`ZIP leído de la dirección (${m[1]})`);
|
||||
}
|
||||
|
||||
// Broker line on GMX: "(1176) Jorge Humberto Cuadros" — the number is the
|
||||
// agent code, the name is what lands on `Policy.agentName`. The parens
|
||||
// are optional: a future layout or scan drop them.
|
||||
const brokerRaw = labelValue(text, /^Broker\s+(?:\(\d+\)\s*)?(.+)$/m);
|
||||
const agentName = brokerRaw?.trim() ?? null;
|
||||
|
||||
// Term: "12 months" — informational, not a free-standing date. Stored in
|
||||
// notes; the UI can derive `coveragePeriodDays` from From/To anyway.
|
||||
const term = firstMatch(text, [/^Term\s+(\d+\s+months?)$/m]);
|
||||
if (term) notes.push(`vigencia: ${term}`);
|
||||
|
||||
const policyFrom = parseDate(
|
||||
labelValue(text, /^From\s+(\d{1,2}\/\d{1,2}\/\d{4})\b/m),
|
||||
);
|
||||
const policyTo = parseDate(
|
||||
firstMatch(text, [/^To\s+(\d{1,2}\/\d{1,2}\/\d{4})\b/m]),
|
||||
);
|
||||
|
||||
// "at twelve hours (noon) Mexico City time." — kept in notes only.
|
||||
if (/twelve\s*hours|noon/i.test(text)) notes.push("vencimiento a las 12:00 hora del centro");
|
||||
|
||||
// The Currency / Premium payment cells sit next to each other on one
|
||||
// line; pull them with bounded matches so the trailing label of the
|
||||
// adjacent cell doesn't swallow the wrong value.
|
||||
const currency = currencyCode(labelValue(text, /^Currency\s+(\S+?)(?:\s+Premium\s+payment|$)/m));
|
||||
const premiumPayment = labelValue(text, /Premium\s+payment\s+(\S+)$/m);
|
||||
|
||||
// ----- signature date (page 1) -----------------------------------------
|
||||
// Appears above the signature line on its own: "July 23, 2026".
|
||||
const dateMatch = text.match(
|
||||
/\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},\s*\d{4}\b/,
|
||||
);
|
||||
const policyDate = dateMatch ? parseDate(dateMatch[0]) : null;
|
||||
if (!policyDate) notes.push("no se pudo leer la fecha de firma");
|
||||
|
||||
// ----- coverages table (page 2) -----------------------------------------
|
||||
const coverages = parseGmxCoverages(text, notes);
|
||||
|
||||
if (!policyNumber) notes.push("no se pudo leer el número de póliza");
|
||||
if (!policyFrom || !policyTo) notes.push("no se pudo leer el período de vigencia");
|
||||
// Premium fields are expected to be missing on the certificate page; flag
|
||||
// it explicitly so the reviewer knows to look for a separate receipt.
|
||||
if (!text.match(/Prima\s*neta|net\s*premium/i)) {
|
||||
notes.push("esta página no trae prima; revisar el recibo de GMX por separado");
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "GMX",
|
||||
policyNumber: policyNumber ? policyNumber.replace(/\s+/g, "") : null,
|
||||
insuredName,
|
||||
additionalInsured,
|
||||
agentName,
|
||||
legalAddress,
|
||||
zip,
|
||||
policyFrom,
|
||||
policyTo,
|
||||
policyDate,
|
||||
currency,
|
||||
netPremium: null,
|
||||
policyFee: null,
|
||||
brokerFee: null,
|
||||
total: null,
|
||||
premiumPayment,
|
||||
coverages,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the value that follows a `LABEL` on the same line. Used by every
|
||||
* "Label Value" cell on the GMX header table — matches on the line
|
||||
* itself rather than across the page, so a label that also appears in body
|
||||
* text can't accidentally claim a different cell.
|
||||
*/
|
||||
function labelValue(text: string, pattern: RegExp): string | null {
|
||||
const m = text.match(pattern);
|
||||
if (!m?.[1]) return null;
|
||||
return m[1].replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the GMX per-coverage table on page 2.
|
||||
*
|
||||
* Real sample row (single-line representation of the table after pdftotext
|
||||
* flattens it; the real layout uses fixed columns):
|
||||
* "Building $350,000.00 Not applies Not applies"
|
||||
*
|
||||
* The four columns are:
|
||||
* Risk (left), Insured Amount ($ figure OR the word "Covered"),
|
||||
* Deductible (free text — "Not applies", "5%", "2% of the sum insured…"),
|
||||
* Loss Participation (same).
|
||||
*
|
||||
* "Covered" means the coverage is included with no dollar cap. We record
|
||||
* the word so the review queue surfaces it instead of inventing a number.
|
||||
*
|
||||
* Deductible / Loss Participation are kept as printed strings, not
|
||||
* converted to numbers — a "20%" loss participation is a different field
|
||||
* shape from a "$5,000" deductible and the JSON column lets the UI render
|
||||
* either verbatim.
|
||||
*
|
||||
* Multi-line cells (the "Earthquake" row's deductible wraps to three lines
|
||||
* because the column is narrow) are collapsed by joining consecutive
|
||||
* non-table-body lines onto the previous row's deductible cell before
|
||||
* applying the column regex.
|
||||
*/
|
||||
function parseGmxCoverages(text: string, notes: string[]): ParsedCoverage[] {
|
||||
const out: ParsedCoverage[] = [];
|
||||
|
||||
// Stop at "VALUES ADDED" — the trailing prose section (homeowner
|
||||
// services, legal text) is not a coverage table. Re-enter at
|
||||
// "ADDITIONAL RISK" for the second coverage block on page 2.
|
||||
const segments = text.split(/VALUES\s*ADDED/i)[0].split(/ADDITIONAL\s*RISK/i);
|
||||
|
||||
// `[ \t]` (not `\s`) inside a cell: the deductible/loss-participation
|
||||
// columns may wrap onto several lines in the raw `pdftotext` output, and
|
||||
// matching across newlines silently swallows the next row.
|
||||
const re = /^([A-Za-zÁÉÍÓÚÑ][A-Za-zÁÉÍÓÚÑ /\-.]+?)[ \t]+(\$[\d,.]+|Covered|Not[ \t]+applies)[ \t]+(\S+(?:[ \t]\S+){0,8})[ \t]+(\S+(?:[ \t]\S+){0,8})[ \t]*$/gim;
|
||||
let m: RegExpExecArray | null;
|
||||
for (const seg of segments) {
|
||||
re.lastIndex = 0;
|
||||
while ((m = re.exec(seg)) !== null) {
|
||||
const risk = m[1].trim();
|
||||
const amountCell = m[2].trim();
|
||||
const deductible = m[3].trim();
|
||||
const lossParticipation = m[4].trim();
|
||||
|
||||
// Skip the "Risk / Insured Amount / Deductible / Loss Participation"
|
||||
// header row itself, which matches the same regex.
|
||||
if (/^Risk$/i.test(risk) && /Insured\s*Amount/i.test(amountCell)) continue;
|
||||
|
||||
out.push({
|
||||
risk,
|
||||
insuredAmount:
|
||||
amountCell === "Covered" || amountCell === "Not applies"
|
||||
? null
|
||||
: money(amountCell),
|
||||
deductible,
|
||||
lossParticipation,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (out.length === 0) notes.push("no se encontraron coberturas en la tabla");
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user