feat(policy-ocr): read GMX's Spanish PVL especificación layout
GMX ships two unrelated documents for the same policy and the office downloads both from the same portal. The parser only knew the English caratula, so a `…-CondicionesParticulares.pdf` parsed to an almost entirely empty row — including the policy number, which the matcher needs. `parseGmx` becomes a dispatcher over `parseGmxCaratula` (unchanged behaviour) and the new `parseGmxEspecificacion`. Both still report `provider: "GMX"`: the matcher keys on the policy number alone and must not care which artifact was uploaded. The especificación has no tables. Coverages are found by anchoring on `Límite … Responsabilidad:` and walking backwards for the heading, where a heading is a short line *preceded by a blank line* — length alone cannot tell one from the wrapped tail of the paragraph above it, and without that condition coverages get named after the last word of the preceding prose. Also fixed, both pre-existing: - The policy number's group widths are not the same across the two families (`007-037-…-0000-02` vs `07-037-…-00000-01`). The pinned-width regex is replaced by a shape, so both read. - The caratula's ZIP fallback pushed a note saying it had read the ZIP from the address, then never assigned it. Verified against the full ten-page real document: all 17 coverages, amounts, deductibles and the excluded earthquake section match what is printed. 24 parser tests (was 8), four of them regressions for ways this layout can silently attach the *wrong* value rather than none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -144,4 +144,265 @@ describe("parsePolicy / GMX", () => {
|
||||
expect(eqTyped.deductible).toContain("sum insured");
|
||||
expect(eqTyped.lossParticipation).toBe("20%");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The second GMX document family: the Spanish PVL "especificación" the office
|
||||
* receives as `…-CondicionesParticulares.pdf`. Verbatim excerpts from
|
||||
* `007_LGS-HGMX_07006957_01_0-CondicionesParticulares.pdf` through
|
||||
* `pdftotext -layout`, indentation included — the column positions and the
|
||||
* blank lines between blocks are what the parser reads, so a cleaned-up
|
||||
* fixture would test nothing.
|
||||
*/
|
||||
describe("parsePolicy / GMX especificación (PVL Hogar)", () => {
|
||||
const HEADER =
|
||||
" ESPECIFICACIÓN QUE SE ADHIERE Y FORMA PARTE INTEGRANTE DE LA PÓLIZA\n" +
|
||||
" 07-037-07006957-00000-01\n" +
|
||||
"\n";
|
||||
|
||||
const GMX_ESPEC = page(
|
||||
HEADER +
|
||||
"\n" +
|
||||
" Nombre del asegurado EMMER . KATHLEEN\n" +
|
||||
"\n" +
|
||||
" Tipo Persona Asegurada Propietario\n" +
|
||||
"\n" +
|
||||
" Ubicación del riesgo LOS PELICANOS ESTE NO. 98 Col. LAS GAVIOTAS PLAYAS\n" +
|
||||
" DE ROSARITO BAJA CALIFORNIA 22713\n" +
|
||||
"\n" +
|
||||
" Características del Inmueble Casa Tipo constructivo Combinado: Macizo y Madera.\n" +
|
||||
" Consta de 2 pisos incluyendo sótanos y planta baja.\n" +
|
||||
"\n" +
|
||||
" -500 mts.cuerpo agua SI\n" +
|
||||
"\n" +
|
||||
" Asegurado Adicional\n" +
|
||||
"\n" +
|
||||
"PVL Hogar - GMX Seguros Página: 1 de 10\n" +
|
||||
HEADER +
|
||||
"\n" +
|
||||
" SECCIÓN INCENDIO EDIFICIO Y CONTENIDOS\n" +
|
||||
"\n" +
|
||||
" EDIFICIO\n" +
|
||||
"\n" +
|
||||
" Límite Máximo de Responsabilidad:\n" +
|
||||
" $200,000.00 USD\n" +
|
||||
"\n" +
|
||||
" Quedan amparados los muros de contención y bardas, así como puertas y portones, hasta un sublimite de $ 50,000.00 M.N. o su\n" +
|
||||
" equivalente en dólares americanos, o hasta el 10% de la suma asegurada de la sección de Edificio, lo que resulte menor.\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
" CONTENIDOS\n" +
|
||||
"\n" +
|
||||
" Límite Máximo de Responsabilidad:\n" +
|
||||
" $20,000.00 USD\n" +
|
||||
"\n" +
|
||||
" 2. Terremoto o erupción volcánica: Sección Edificio EXCLUIDO, Sección Contenidos EXCLUIDO\n" +
|
||||
"\n" +
|
||||
" 3. Fenómenos hidrometeorológicos: Sección Edificio $200,000.00 USD, Sección Contenidos $20,000.00 USD\n" +
|
||||
"\n" +
|
||||
" Riesgos adicionales.\n" +
|
||||
"\n" +
|
||||
" Remoción de escombros\n" +
|
||||
"\n" +
|
||||
" Límite Máximo de Responsabilidad:\n" +
|
||||
" Edificio\n" +
|
||||
" $20,000.00 USD\n" +
|
||||
" Contenidos\n" +
|
||||
" $2,000.00 USD\n" +
|
||||
"\n" +
|
||||
" Gastos extraordinarios para casa habitación\n" +
|
||||
"\n" +
|
||||
" En caso de siniestro por los riesgos cubiertos en esta póliza, GMX Seguros pagará la renta de casa o departamento, casa de\n" +
|
||||
" huéspedes u hotel cuando se asegure el inmueble, así como los gastos de mudanza, seguro de transporte del menaje de casa y\n" +
|
||||
" efectuados.\n" +
|
||||
"\n" +
|
||||
" Límite Máximo de Responsabilidad:\n" +
|
||||
" $22,000.00 USD\n" +
|
||||
" Periodo de indemnización: 4 meses.\n" +
|
||||
"\n" +
|
||||
" Bienes a la Intemperie:\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
" 5 POR CIENTO SOBRE SUMA ASEGURADA, 20 PORCIENTO DE PARTICIPACIÓN A CARGO DEL ASEGURADO DE TODA\n" +
|
||||
" Y CADA PÉRDIDA.\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
" Límite Máximo de Responsabilidad: $10,000.00 USD\n" +
|
||||
"\n" +
|
||||
" DEDUCIBLES:\n" +
|
||||
"\n" +
|
||||
" El procedimiento que se seguirá para la aplicación de deducibles en caso de que la póliza cuente con cláusula inflacionaria en todas\n" +
|
||||
" y/o en algunas de sus coberturas será como sigue:\n" +
|
||||
"\n" +
|
||||
" Fenómenos hidrometeorológicos\n" +
|
||||
" Zona: A2\n" +
|
||||
" Deducible\n" +
|
||||
" Edificio: 1 POR CIENTO SOBRE SUMA ASEGURADA\n" +
|
||||
" Coaseguro:\n" +
|
||||
" Zona 1: (INTERIOR) Participación a cargo del asegurado del 10% de toda y cada pérdida.\n" +
|
||||
" Zona 2: Participación a cargo del asegurado del 10% de toda y cada pérdida.\n" +
|
||||
"\n" +
|
||||
" Deducible\n" +
|
||||
" Contenidos: 1 POR CIENTO SOBRE SUMA ASEGURADA\n" +
|
||||
"\n" +
|
||||
" II.- SECCIÓN DIVERSOS MISCELÁNEOS\n" +
|
||||
"\n" +
|
||||
" ROBO DE CONTENIDOS\n" +
|
||||
"\n" +
|
||||
" Límite de Responsabilidad:\n" +
|
||||
" $4,000.00 USD\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
" Deducible:\n" +
|
||||
" Sin deducible\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
" Sublímites:\n" +
|
||||
" Joyas, artículos de oro y plata, armas, relojes, pieles, piedras preciosas montadas, colecciones, obras de arte y demás que por su\n" +
|
||||
"\n" +
|
||||
"PVL Hogar - GMX Seguros Página: 7 de 10\n" +
|
||||
HEADER +
|
||||
"\n" +
|
||||
" naturaleza se consideran como objetos de difícil o imposible reposición\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
" Límite de Responsabilidad:\n" +
|
||||
"\n" +
|
||||
" $2,000.00 USD\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
" Deducible:\n" +
|
||||
" Sin deducible\n" +
|
||||
"\n" +
|
||||
" Las condiciones generales que forman parte de la presente póliza son las identificadas bajo el nombre:\n" +
|
||||
" W_HogarGMX_12.11.2025.pdf\n" +
|
||||
"\n" +
|
||||
"PVL Hogar - GMX Seguros Página: 10 de 10\n",
|
||||
);
|
||||
|
||||
const byRisk = (p: ReturnType<typeof parsePolicy>) =>
|
||||
Object.fromEntries(p.coverages.map((c) => [c.risk, c]));
|
||||
|
||||
it("reads a policy number whose groups are not the caratula's widths", () => {
|
||||
// 2-3-8-5-2 here vs 3-3-8-4-2 on the English caratula. Pinning the widths
|
||||
// reads one family and returns null on the other.
|
||||
expect(parsePolicy(GMX_ESPEC).policyNumber).toBe("07-037-07006957-00000-01");
|
||||
});
|
||||
|
||||
it("reads the insured, the risk location across its wrapped line, and the ZIP", () => {
|
||||
const p = parsePolicy(GMX_ESPEC);
|
||||
expect(p.provider).toBe("GMX");
|
||||
expect(p.insuredName).toBe("EMMER . KATHLEEN");
|
||||
expect(p.legalAddress).toBe(
|
||||
"LOS PELICANOS ESTE NO. 98 Col. LAS GAVIOTAS PLAYAS DE ROSARITO BAJA CALIFORNIA 22713",
|
||||
);
|
||||
expect(p.zip).toBe("22713");
|
||||
// The cell is printed but empty on this policy — an empty label must not
|
||||
// capture the next line of the form.
|
||||
expect(p.additionalInsured).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves the fields this document does not carry null, and says so", () => {
|
||||
const p = parsePolicy(GMX_ESPEC);
|
||||
expect(p.policyFrom).toBeNull();
|
||||
expect(p.policyTo).toBeNull();
|
||||
expect(p.policyDate).toBeNull();
|
||||
expect(p.agentName).toBeNull();
|
||||
expect(p.netPremium).toBeNull();
|
||||
expect(p.total).toBeNull();
|
||||
expect(p.notes.join(" ")).toMatch(/no trae vigencia, agente ni prima/i);
|
||||
});
|
||||
|
||||
it("takes the currency from the printed limits, not from the M.N. sublimits", () => {
|
||||
// The body prose quotes sublimits in pesos ("$ 50,000.00 M.N."); every
|
||||
// limit is in USD, and only the limits vote.
|
||||
expect(parsePolicy(GMX_ESPEC).currency).toBe("USD");
|
||||
});
|
||||
|
||||
it("reads each coverage under its own heading", () => {
|
||||
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||
expect(c.EDIFICIO?.insuredAmount).toBe(200000);
|
||||
expect(c.CONTENIDOS?.insuredAmount).toBe(20000);
|
||||
expect(c["ROBO DE CONTENIDOS"]?.insuredAmount).toBe(4000);
|
||||
expect(c["ROBO DE CONTENIDOS"]?.deductible).toBe("Sin deducible");
|
||||
});
|
||||
|
||||
it("splits a limit printed under Edificio / Contenidos sub-labels", () => {
|
||||
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||
expect(c["Remoción de escombros — Edificio"]?.insuredAmount).toBe(20000);
|
||||
expect(c["Remoción de escombros — Contenidos"]?.insuredAmount).toBe(2000);
|
||||
});
|
||||
|
||||
it("names a coverage after its heading, not after the wrapped tail of the prose above it", () => {
|
||||
// Walking back from the limit hits "efectuados." — short, and the only
|
||||
// thing separating it from a heading is that it is not preceded by a
|
||||
// blank line.
|
||||
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||
expect(c["Gastos extraordinarios para casa habitación"]?.insuredAmount).toBe(22000);
|
||||
expect(c["efectuados."]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads a limit printed on the label's own line", () => {
|
||||
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||
expect(c["Bienes a la Intemperie"]?.insuredAmount).toBe(10000);
|
||||
});
|
||||
|
||||
it("reads a deductible stated as a sentence above the limit", () => {
|
||||
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||
expect(c["Bienes a la Intemperie"]?.deductible).toBe(
|
||||
"5 POR CIENTO SOBRE SUMA ASEGURADA, 20 PORCIENTO DE PARTICIPACIÓN A CARGO DEL ASEGURADO DE TODA Y CADA PÉRDIDA.",
|
||||
);
|
||||
});
|
||||
|
||||
it("never borrows a neighbouring coverage's prose as a deductible", () => {
|
||||
// "…o hasta el 10% de la suma asegurada de la sección de Edificio" is a
|
||||
// sublimit rule for EDIFICIO, printed two paragraphs above CONTENIDOS.
|
||||
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||
expect(c.CONTENIDOS?.deductible).toBeNull();
|
||||
expect(c.EDIFICIO?.deductible).toBeNull();
|
||||
});
|
||||
|
||||
it("does not read the page-level DEDUCIBLES paragraph as a deductible", () => {
|
||||
const p = parsePolicy(GMX_ESPEC);
|
||||
expect(
|
||||
p.coverages.some((c) => (c.deductible ?? "").includes("cláusula inflacionaria")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reads a sublimit block as a sublimit OF the coverage above it", () => {
|
||||
// The amount sits after a blank line AND a page break, and the block's
|
||||
// own heading ("Sublímites:") names no risk.
|
||||
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||
expect(c["ROBO DE CONTENIDOS — sublímite"]?.insuredAmount).toBe(2000);
|
||||
});
|
||||
|
||||
it("records an excluded catastrophic risk as excluded, never as zero", () => {
|
||||
const p = parsePolicy(GMX_ESPEC);
|
||||
const quake = p.coverages.filter((c) => /Terremoto/i.test(c.risk));
|
||||
expect(quake).toHaveLength(2);
|
||||
for (const c of quake) {
|
||||
expect(c.risk).toMatch(/EXCLUIDO/);
|
||||
// A coverage insured for $0 and an excluded coverage are the same
|
||||
// number and very different facts.
|
||||
expect(c.insuredAmount).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("attaches the hydrometeorological deductible and coinsurance from its own block", () => {
|
||||
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||
const building = c["Fenómenos hidrometeorológicos — Sección Edificio"];
|
||||
expect(building?.insuredAmount).toBe(200000);
|
||||
expect(building?.deductible).toBe("1 POR CIENTO SOBRE SUMA ASEGURADA");
|
||||
expect(building?.lossParticipation).toBe("10%");
|
||||
expect(c["Fenómenos hidrometeorológicos — Sección Contenidos"]?.insuredAmount).toBe(20000);
|
||||
});
|
||||
|
||||
it("carries the underwriting context the fields have no home for", () => {
|
||||
const notes = parsePolicy(GMX_ESPEC).notes.join(" | ");
|
||||
expect(notes).toMatch(/tipo de persona asegurada: Propietario/);
|
||||
expect(notes).toMatch(/características del inmueble: Casa/);
|
||||
expect(notes).toMatch(/cuerpo de agua/);
|
||||
expect(notes).toMatch(/zona catastrófica declarada: A2/);
|
||||
expect(notes).toMatch(/W_HogarGMX_12\.11\.2025\.pdf/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user