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 = { 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; } function allMatches(text: string, pattern: RegExp): string[] { const re = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`); const out: string[] = []; let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { if (m[1]) out.push(m[1]); if (m.index === re.lastIndex) re.lastIndex++; } return out; } /** * The printed policy number, as a shape rather than as fixed widths. * * Both GMX document families print the same five dash-separated groups, but * NOT with the same widths: the English caratula reads * `007-037-07005947-0000-02` and the Spanish PVL especificación reads * `07-037-07006957-00000-01` — 2 digits in the first group and 5 in the * fourth. Pinning the widths (as the first version of this parser did) reads * one family and silently returns null on the other, which the matcher then * reports as "no se pudo leer el número de póliza" on a page where the number * is printed ten times. * * The OCR confusion class is allowed inside the groups but NOT normalised * here — `normalizePolicyNumber` does that, so the raw read stays available. */ const POLICY_NUMBER_SHAPE = /\b([0-9OIlSBD]{2,4}[-\s][0-9OIlSBD]{3}[-\s][0-9OIlSBD]{6,10}[-\s][0-9OIlSBD]{4,6}[-\s][0-9OIlSBD]{2})\b/; /** * Strip layout whitespace and fold the Tesseract digit confusions, keeping the * dashes: they are part of the number as the office keys it, and * `PolicyMatcherService` matches `Policy.policyNumber` exactly. */ function normalizePolicyNumber(raw: string | null | undefined): string | null { if (!raw) return null; const out = raw .replace(/\s+/g, "") .split("") .map((c) => (c === "-" ? c : (DIGIT_CONFUSIONS[c] ?? c))) .join(""); return out || null; } const MONTHS: Record = { 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 = { 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|PVL\s*Hogar|ESPECIFICACI[ÓO]N\s*QUE\s*SE\s*ADHIERE/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 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 ships (at least) two unrelated document families, and the office * downloads both from the same portal for the same policy: * * 1. **Caratula / translation** (`…_Traduccion.pdf`) — the English-labelled * certificate: a boxed header table (Policy / Insured / Broker / From / To * / Currency) and a four-column coverage table. * 2. **Especificación** (`…-CondicionesParticulares.pdf`) — the Spanish * "ESPECIFICACIÓN QUE SE ADHIERE Y FORMA PARTE INTEGRANTE DE LA PÓLIZA" * issued by GMX's Punto de Venta en Línea (PVL). Ten pages of prose with * NO header table, NO dates, NO broker, and the coverages expressed as * section headings above a `Límite Máximo de Responsabilidad:` label. * * They share only the brand and the policy number, so they get separate * parsers behind one provider. Both still return `provider: "GMX"` — the * matcher keys on the policy number alone and must not care which artifact * the office happened to upload. */ function parseGmx(page: OcrPage): ParsedPolicy { return isEspecificacion(page.text) ? parseGmxEspecificacion(page) : parseGmxCaratula(page); } /** The PVL especificación announces itself in its own repeated page header. */ function isEspecificacion(text: string): boolean { return /ESPECIFICACI[ÓO]N\s+QUE\s+SE\s+ADHIERE|PVL\s+Hogar|Nombre\s+del\s+asegurado/i.test(text); } /** * 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 parseGmxCaratula(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, [ new RegExp(`\\bPolicy\\s+${POLICY_NUMBER_SHAPE.source}`, "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); let 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(?!.*\b\d{5}\b)/); if (m) { zip = m[1]; 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: normalizePolicyNumber(policyNumber), 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; } // --- GMX / PVL especificación ----------------------------------------------- /** * Page furniture on the PVL especificación: the two-line header that repeats * on all ten pages (title + policy number on its own line) and the footer. * Skipped when hunting for a coverage's heading, or the heading found would * be the page break rather than the risk. */ const ESPEC_FURNITURE = /^(ESPECIFICACI[ÓO]N\s+QUE\s+SE\s+ADHIERE|PVL\s+Hogar\s*-\s*GMX|P[áa]gina\s*:|La\s+presente\s+p[óo]liza\s+queda\s+sujeta)/i; /** * Labels that structure a coverage block rather than name one. A heading walk * that accepts these ends up calling every coverage "Deducible:". */ const ESPEC_STRUCTURAL = /^(L[íi]mites?\b|Deducibles?\b|Coaseguros?\b|Bienes\s+cubiertos|Riesgos\s+cubiertos|Riesgos\s+adicionales|Exclusiones?\b|Zona\s*:|Periodo\s+de\s+indemnizaci[óo]n|Nota\s+aclaratoria|CONDICIONES\s+ESPECIALES|IMPORTANTE\b|SECCI[ÓO]N\b|II+\.-|[●•]|\d+\.\s)/i; /** `$200,000.00 USD`, `$ 35,050.00 M.N.` — amount plus its printed currency. */ const ESPEC_AMOUNT = /\$\s*([\d,]+(?:\.\d{2})?)\s*(USD|MXN|M\.?\s?N\.?|D[LlÓó]{1,3}[Ss]?)?/; /** The label that introduces every insured amount on this layout. */ const ESPEC_LIMIT_LABEL = /^\s*L[íi]mite\s+(?:M[áa]ximo\s+)?(?:de\s+)?Responsabilidad\s*:?(.*)$/i; /** * GMX's PVL "especificación" layout — ten pages of Spanish prose that the * office receives as `…-CondicionesParticulares.pdf`. * * What this document DOES carry: the policy number (printed on every page * header), the insured's name, the risk location, and every coverage limit. * * What it does NOT carry, at all: policy dates, broker, premium, currency as * a labelled field, or a coverage table. There is nothing to read for those, * so they stay null and the notes say why — the caratula is the document that * has them, and the office downloads both for the same policy. * * Coverages are not tabular here. Each one is a section heading followed by * `Límite Máximo de Responsabilidad:` and an amount on the next line (or two * amounts under `Edificio` / `Contenidos` sub-labels), with the deductible * printed further down its own block. So the parser anchors on the limit * label and walks BACKWARDS for the heading, rather than trying to match a * row shape that does not exist. */ function parseGmxEspecificacion(page: OcrPage): ParsedPolicy { const text = page.text; const lines = text.split("\n"); const notes: string[] = []; // ----- policy number ----------------------------------------------------- // Printed on its own line under the title on all ten pages. Ten independent // readings of the same number is a free cross-check: on a scan, a group that // OCR'd two different ways means one reading is wrong and there is no third // to break the tie, so the row goes to a human (the same rule the zona // federal statement parser applies to its clave). const readings = [ ...new Set( allMatches(text, POLICY_NUMBER_SHAPE) .map(normalizePolicyNumber) .filter((n): n is string => n != null), ), ]; const policyNumber = readings[0] ?? null; if (!policyNumber) notes.push("no se pudo leer el número de póliza"); if (readings.length > 1) { notes.push( `el número de póliza se leyó de ${readings.length} formas distintas (${readings.join(", ")})`, ); } // ----- insured + risk location ------------------------------------------ // Values wrap onto the following lines within the same visual cell, and a // blank line always ends the cell, so the block reader joins until one. const insuredName = espectBlock(lines, /^\s*Nombre\s+del\s+asegurado\s+(.+)$/i); const additionalInsured = espectBlock(lines, /^\s*Asegurado\s+Adicional\s+(\S.*)$/i); const legalAddress = espectBlock(lines, /^\s*Ubicaci[óo]n\s+del\s+riesgo\s+(.+)$/i); // No ZIP field on this layout; it is the trailing run of the address // ("…BAJA CALIFORNIA 22713"). Take the LAST five-digit run — a street // number earlier in the line is never five digits, but anchoring on the // first match would still be the fragile choice. let zip: string | null = null; if (legalAddress) { const m = legalAddress.match(/(\d{5})(?!.*\d{5})/); if (m) { zip = m[1]; notes.push(`código postal leído de la ubicación del riesgo (${m[1]})`); } } // ----- underwriting context --------------------------------------------- // Not `ParsedPolicy` fields, but they are the difference between a coverage // a reviewer can price and one they cannot, so they ride along in notes. const personType = espectBlock(lines, /^\s*Tipo\s+Persona\s+Asegurada\s+(.+)$/i); if (personType) notes.push(`tipo de persona asegurada: ${personType}`); const building = espectBlock(lines, /^\s*Caracter[íi]sticas\s+del\s+Inmueble\s+(.+)$/i); if (building) notes.push(`características del inmueble: ${building}`); const water = firstMatch(text, [/^\s*(-?\s*\d+\s*mts\.?\s*cuerpo\s+agua\s+\S+)\s*$/im]); if (water) notes.push(`distancia a cuerpo de agua: ${water.replace(/\s+/g, " ")}`); const generalConditions = firstMatch(text, [/^\s*(W_\S+\.pdf)\s*$/im]); if (generalConditions) notes.push(`condiciones generales: ${generalConditions}`); // ----- coverages --------------------------------------------------------- const { coverages, currency } = parseEspecificacionCoverages(lines, notes); // These are absent by design on this document, not failures to read. Say so // explicitly, or the reviewer reads four empty fields as a broken parse. notes.push( "la especificación PVL no trae vigencia, agente ni prima; esos datos están en la carátula de la póliza", ); return { provider: "GMX", policyNumber, insuredName, additionalInsured, agentName: null, legalAddress, zip, policyFrom: null, policyTo: null, policyDate: null, currency, netPremium: null, policyFee: null, brokerFee: null, total: null, premiumPayment: null, coverages, notes, }; } /** * Read a `Label Value` cell whose value wraps onto the following lines. * * `pdftotext -layout` renders the especificación's header cells with the * value in a right-hand column, so a two-line risk location comes back as the * label line plus a continuation line indented to the same column. A blank * line always terminates the cell. */ function espectBlock(lines: string[], label: RegExp): string | null { for (let i = 0; i < lines.length; i++) { const m = lines[i].match(label); if (!m?.[1]) continue; const parts = [m[1]]; for (let j = i + 1; j < lines.length && lines[j].trim(); j++) { parts.push(lines[j].trim()); } const value = parts.join(" ").replace(/\s+/g, " ").trim(); return value || null; } return null; } /** * Walk the especificación's coverages by anchoring on the limit label. * * For each `Límite … Responsabilidad:` the amount is either on the same line * ("Bienes a la Intemperie"), on the next line (most), or split across two * lines under `Edificio` / `Contenidos` sub-labels ("Remoción de escombros"). * The risk name is the nearest heading ABOVE the label: a short line that is * itself preceded by a blank one. That last condition is what distinguishes a * heading from the short tail of a wrapped paragraph ("efectuados.", * "Y CADA PÉRDIDA."), which is otherwise indistinguishable by length alone * and would name a coverage after the last word of the prose above it. * * Returns the currency alongside, because on this layout there is no currency * field — it is only ever printed next to the amounts, and the sublimits in * the body prose are quoted in M.N. while the limits themselves are in USD. * Only the limit amounts vote. */ function parseEspecificacionCoverages( lines: string[], notes: string[], ): { coverages: ParsedCoverage[]; currency: string | null } { const coverages: ParsedCoverage[] = []; const currencyVotes: Record = {}; let lastHeading: string | null = null; const limitLines: number[] = []; lines.forEach((line, i) => { if (ESPEC_LIMIT_LABEL.test(line)) limitLines.push(i); }); limitLines.forEach((i, n) => { const nextLimit = limitLines[n + 1] ?? lines.length; const heading = espectHeadingAbove(lines, i); // "Sublímites:" / "Límite adicional." head a block that qualifies the // coverage above them rather than naming a new one, so they are read as // a sublimit OF the previous risk instead of as a standalone coverage. let risk = heading ?? "(sin nombre)"; if (/^Subl[íi]mites?\b|^L[íi]mite\s+adicional/i.test(risk) && lastHeading) { risk = `${lastHeading} — sublímite`; } else if (heading) { lastHeading = heading; } if (!heading) notes.push(`un límite no tiene encabezado identificable (línea ${i + 1})`); const deductible = espectDeductibleFor(lines, i, nextLimit); const found = espectAmountsFor(lines, i); if (found.length === 0) { notes.push(`no se pudo leer el monto de "${risk}"`); coverages.push({ risk, insuredAmount: null, deductible, lossParticipation: null }); return; } for (const a of found) { if (a.currency) currencyVotes[a.currency] = (currencyVotes[a.currency] ?? 0) + 1; coverages.push({ risk: a.sublabel ? `${risk} — ${a.sublabel}` : risk, insuredAmount: a.amount, deductible, lossParticipation: null, }); } }); // The per-section coverages that are stated inline rather than under a // limit label. On this policy they are the two that matter most on the // coast — earthquake (EXCLUIDO here) and hydrometeorological — and skipping // them would leave the biggest exposure off the parsed row entirely. coverages.push(...espectInlineSectionRisks(lines, currencyVotes, notes)); if (coverages.length === 0) notes.push("no se encontraron coberturas"); const currency = Object.entries(currencyVotes).sort((a, b) => b[1] - a[1])[0]?.[0] ?? null; if (currency) { notes.push(`moneda tomada de los límites impresos (${currency})`); } else { notes.push("no se pudo determinar la moneda de las sumas aseguradas"); } return { coverages, currency }; } /** * The nearest line above `at` that reads as a heading: short, not page * furniture, not a structural label, and itself preceded by a blank line. */ function espectHeadingAbove(lines: string[], at: number): string | null { for (let i = at - 1, walked = 0; i >= 0 && walked < 40; i--, walked++) { if (!isEspecHeading(lines, i)) continue; return lines[i].trim().replace(/\s*[:.]$/, ""); } return null; } /** * Does line `i` read as a coverage heading? * * Short, not page furniture, not a structural label — and preceded by a blank * line, which is the condition that actually does the work: a heading sits in * its own paragraph, and the short tail of a wrapped sentence ("efectuados.", * "Y CADA PÉRDIDA.") does not. Length alone cannot tell those apart. */ function isEspecHeading(lines: string[], i: number): boolean { const line = lines[i]?.trim() ?? ""; if (!line) return false; if (ESPEC_FURNITURE.test(line)) return false; // The repeated policy-number line in the page header. if (POLICY_NUMBER_SHAPE.test(line) && line.replace(/[\d\sOIlSBD-]/g, "") === "") return false; if (ESPEC_STRUCTURAL.test(line)) return false; if (line.length > 60) return false; // An amount on its own line is a value, never a heading — the sublimit // block prints one after a blank line, exactly where a heading would sit. if (/^\$?\s*[\d,]+(?:\.\d{2})?\s*(USD|MXN|M\.?\s?N\.?|DLLS?)?$/i.test(line)) return false; return i > 0 && !lines[i - 1].trim(); } /** * Amounts belonging to one limit label: on the label line itself, then on the * following lines up to the blank that closes the block. A short non-amount * line inside the block is a sub-label for the amount beneath it * (`Edificio` / `Contenidos`). */ function espectAmountsFor( lines: string[], at: number, ): { amount: number | null; currency: string | null; sublabel: string | null }[] { const out: { amount: number | null; currency: string | null; sublabel: string | null }[] = []; const push = (line: string, sublabel: string | null) => { const m = line.match(ESPEC_AMOUNT); if (!m) return false; out.push({ amount: money(m[1]), currency: espectCurrency(m[2]), sublabel }); return true; }; const tail = lines[at].match(ESPEC_LIMIT_LABEL)?.[1]?.trim() ?? ""; if (tail && push(tail, null)) return out; let sublabel: string | null = null; let seenContent = false; for (let i = at + 1; i < lines.length; i++) { const line = lines[i].trim(); // A blank line closes the block — but only once the block has started. // The label and its amount are not always adjacent: the sublimit block on // page 7 renders as "Límite de Responsabilidad:", a blank line, then // "$2,000.00 USD", and breaking on the first blank drops that amount. if (!line) { if (seenContent) break; if (i - at > 3) break; continue; } if (ESPEC_FURNITURE.test(line)) continue; if (push(line, sublabel)) { sublabel = null; seenContent = true; continue; } seenContent = true; // Not an amount: a sub-label if it is short enough to be one. if (line.length <= 40 && !ESPEC_STRUCTURAL.test(line)) sublabel = line.replace(/\s*:$/, ""); } return out; } /** * The deductible printed inside this coverage's own block, i.e. between its * limit and the next one. * * The value follows a bare `Deducible:` on the next line ("Sin deducible"). * The length guard is load-bearing: the document also has a page-level * `DEDUCIBLES:` heading followed by a paragraph explaining how deductibles * interact with the inflation clause, and without the guard that paragraph * becomes the deductible of whichever coverage precedes it. */ function espectDeductibleFor(lines: string[], from: number, to: number): string | null { for (let i = from + 1; i < to && i < from + 30; i++) { const line = lines[i].trim(); // A heading ends this coverage's block. Not every block that follows a // limit carries one of its own — the per-zone `Fenómenos // hidrometeorológicos` deductible table has no limit label to stop at, so // without this the coverage above it silently adopts its "1 POR CIENTO // SOBRE SUMA ASEGURADA". if (isEspecHeading(lines, i)) break; const inline = line.match(/^Deducibles?\s*:\s*(\S.*)$/i); if (inline) return inline[1].trim(); if (!/^Deducibles?\s*:?\s*$/i.test(line)) continue; for (let j = i + 1; j < to; j++) { const value = lines[j].trim(); if (!value) continue; // Too long to be a deductible: this is the page-level `DEDUCIBLES:` // heading and the paragraph explaining the inflation clause, not this // coverage's value. Keep looking rather than concluding "none". if (value.length <= 60) return value; break; } } return espectPercentDeductibleAbove(lines, from); } /** * Some coverages print their deductible and coinsurance as a sentence ABOVE * the limit instead of under a `Deducible:` label below it — "Bienes a la * Intemperie" reads * * 5 POR CIENTO SOBRE SUMA ASEGURADA, 20 PORCIENTO DE PARTICIPACIÓN A CARGO * DEL ASEGURADO DE TODA Y CADA PÉRDIDA. * * wrapped across two lines. Left unread it looks like a coverage with no * deductible at all, which is the one wrong answer worth avoiding here. * * Two bounds keep it from wandering into the body prose, and both are * load-bearing — without them the fire section's "…o hasta el 10% de la suma * asegurada de la sección de Edificio, lo que resulte menor" (a sublimit rule * for the building, several paragraphs up) becomes the deductible of * CONTENIDOS: * * - the walk stops at this coverage's own heading, so it can never read text * belonging to the coverage above it, and * - the line must OPEN with the percentage, which a prose sentence that * merely contains one does not. */ function espectPercentDeductibleAbove(lines: string[], limitAt: number): string | null { for (let i = limitAt - 1, walked = 0; i >= 0 && walked < 6; i--, walked++) { const line = lines[i].trim(); if (!line) continue; // The heading of this coverage: stop, everything above belongs to another. if (line.length <= 60 && !ESPEC_STRUCTURAL.test(line) && i > 0 && !lines[i - 1].trim()) { return null; } if (!/^\d+\s*(POR\s*CIENTO|PORCIENTO|%)/i.test(line)) continue; // Re-join the wrapped continuation lines beneath it. const parts = [line]; for (let j = i + 1; j < limitAt && lines[j].trim(); j++) parts.push(lines[j].trim()); return parts.join(" ").replace(/\s+/g, " "); } return null; } /** * Coverages the especificación states inline inside the fire section instead * of under a limit label: * * "2. Terremoto o erupción volcánica: Sección Edificio EXCLUIDO, Sección Contenidos EXCLUIDO" * "3. Fenómenos hidrometeorológicos: Sección Edificio $200,000.00 USD, Sección Contenidos $20,000.00 USD" * * An exclusion is recorded in the risk label rather than as a zero amount: * a coverage that is excluded and a coverage insured for $0 are the same * number and very different facts, and `ParsedCoverage` has no field for the * distinction. */ function espectInlineSectionRisks( lines: string[], currencyVotes: Record, notes: string[], ): ParsedCoverage[] { const out: ParsedCoverage[] = []; const cat = espectCatastrophicTerms(lines, notes); for (const raw of lines) { const m = raw.trim().match(/^\d+\.\s*([^:]{3,60}?)\s*:\s*(.*Secci[óo]n.+)$/i); if (!m) continue; const risk = m[1].trim(); for (const part of m[2].split(/,\s*(?=Secci[óo]n)/i)) { const section = part.match(/Secci[óo]n\s+(\S+)/i)?.[1] ?? null; const label = section ? `${risk} — Sección ${section}` : risk; // Deductible/coinsurance for these two live in their own block further // down the document, keyed by section rather than printed here. const terms = /hidrometeorol/i.test(risk) && section ? cat[section.toLowerCase()] : undefined; if (/EXCLUIDO|NO\s+CUBIERT/i.test(part)) { out.push({ risk: `${label}: EXCLUIDO`, insuredAmount: null, deductible: null, lossParticipation: null, }); continue; } const amount = part.match(ESPEC_AMOUNT); if (!amount) continue; const currency = espectCurrency(amount[2]); if (currency) currencyVotes[currency] = (currencyVotes[currency] ?? 0) + 1; out.push({ risk: label, insuredAmount: money(amount[1]), deductible: terms?.deductible ?? null, lossParticipation: terms?.coinsurance ?? null, }); } } return out; } /** * The `Fenómenos hidrometeorológicos` block, which states the deductible and * the coinsurance per section: * * Fenómenos hidrometeorológicos * Zona: A2 * Deducible * Edificio: 1 POR CIENTO SOBRE SUMA ASEGURADA * Coaseguro: * Zona 1: (INTERIOR) Participación a cargo del asegurado del 10% … * Zona 2: Participación a cargo del asegurado del 10% … * * The coinsurance is quoted per catastrophe zone, and the policy's own zone * ("A2") is not one of the labels used in that list — so it is only read when * every listed zone quotes the SAME percentage, which makes the mapping moot. * When they differ, the reviewer is told to read the zone table by hand * rather than being handed a percentage picked by guesswork. */ function espectCatastrophicTerms( lines: string[], notes: string[], ): Record { const out: Record = {}; const start = lines.findIndex((l) => /^\s*Fen[óo]menos\s+hidrometeorol[óo]gicos\s*$/i.test(l)); if (start < 0) return out; let end = lines.length; for (let i = start + 1; i < lines.length; i++) { if (/^\s*(I+\.-\s*)?SECCI[ÓO]N\b/i.test(lines[i])) { end = i; break; } } const block = lines.slice(start, end); const zone = firstMatch(block.join("\n"), [/^\s*Zona\s*:\s*(\S+)\s*$/im]); if (zone) notes.push(`zona catastrófica declarada: ${zone}`); const percents = [ ...new Set( allMatches( block.join("\n"), /Participaci[óo]n\s+a\s+cargo\s+del\s+asegurado\s+del\s+(\d+%)/i, ), ), ]; let coinsurance: string | null = null; if (percents.length === 1) { coinsurance = percents[0]; } else if (percents.length > 1) { notes.push( `el coaseguro de fenómenos hidrometeorológicos varía por zona (${percents.join(", ")}); revisarlo a mano`, ); } for (const line of block) { const m = line.trim().match(/^(Edificio|Contenidos)\s*:\s*(\S.+)$/i); if (!m) continue; out[m[1].toLowerCase()] = { deductible: m[2].trim(), coinsurance }; } return out; } /** `USD` / `M.N.` / `DLLS` as printed next to an amount → an ISO code. */ function espectCurrency(raw: string | null | undefined): string | null { if (!raw) return null; const s = raw.replace(/[.\s]/g, "").toUpperCase(); if (s === "USD" || s.startsWith("DL") || s.startsWith("DLL")) return "USD"; if (s === "MN" || s === "MXN") return "MXN"; return null; }