From 966fa1f416541390605246c04c7821a1c40386a0 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:43:00 +0800 Subject: [PATCH] Guard untyped bbox jsonb with shared normalizeImageBbox helper document_images.bbox is untyped jsonb, but bboxLooksLikeHeaderOrFooter array-destructured it after only a truthiness check, so an object-shaped bbox row crashed the whole enrichment run with a TypeError. The enrich script and rag.ts also cast the raw jsonb straight to a tuple type. Add normalizeImageBbox to image-filtering.ts (validates a 4-element array of finite numbers, else null), use it inside bboxLooksLikeHeaderOrFooter, and replace the unsafe casts at both jsonb read sites. Matches the chunkImageBbox semantics from the pr131 branch. Co-Authored-By: Claude Fable 5 --- scripts/enrich-documents.ts | 3 ++- src/lib/image-filtering.ts | 13 ++++++++++--- src/lib/rag.ts | 4 ++-- tests/image-filtering.test.ts | 28 ++++++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/scripts/enrich-documents.ts b/scripts/enrich-documents.ts index c120817cfa..68a17c2a23 100644 --- a/scripts/enrich-documents.ts +++ b/scripts/enrich-documents.ts @@ -268,6 +268,7 @@ async function classifyExistingImages(supabase: SupabaseAdmin, documentId: strin classifiedImageSkipReason, clinicalImagePolicyVersion, lightweightPerceptualHash, + normalizeImageBbox, }, { classifyAndCaptionImageFromBase64 }, ] = await Promise.all([import("@/lib/env"), import("@/lib/image-filtering"), import("@/lib/openai")]); @@ -311,7 +312,7 @@ async function classifyExistingImages(supabase: SupabaseAdmin, documentId: strin imageHash, seenHashes, image: { - bbox: image.bbox as [number, number, number, number] | null, + bbox: normalizeImageBbox(image.bbox), width: image.width, height: image.height, sourceKind: image.source_kind as diff --git a/src/lib/image-filtering.ts b/src/lib/image-filtering.ts index facade75b7..72e75a957e 100644 --- a/src/lib/image-filtering.ts +++ b/src/lib/image-filtering.ts @@ -274,9 +274,16 @@ export function isClinicalImageEvidence(image: { return assessment.clinical_use_class === "clinical_evidence"; } -function bboxLooksLikeHeaderOrFooter(bbox: ExtractedImage["bbox"]) { - if (!bbox) return false; - const [, y0, , y1] = bbox; +export function normalizeImageBbox(value: unknown): [number, number, number, number] | null { + if (!Array.isArray(value) || value.length !== 4) return null; + const coords = value.map((entry) => Number(entry)); + return coords.every(Number.isFinite) ? (coords as [number, number, number, number]) : null; +} + +function bboxLooksLikeHeaderOrFooter(bbox: unknown) { + const coords = normalizeImageBbox(bbox); + if (!coords) return false; + const [, y0, , y1] = coords; const height = Math.abs(y1 - y0); if (height > 110) return false; return y1 < 105 || y0 > 705; diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 6262c3426b..52e6bb4eb0 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -35,7 +35,7 @@ import { logger } from "@/lib/logger"; import { queryCacheKeyForStorage, queryPrivacyMetadata, queryTextForStorage } from "@/lib/query-privacy"; import { normalizeSourceMetadata } from "@/lib/source-metadata"; import { isReviewedTablePromotable } from "@/lib/table-review"; -import { isClinicalImageEvidence } from "@/lib/image-filtering"; +import { isClinicalImageEvidence, normalizeImageBbox } from "@/lib/image-filtering"; import { chooseAnswerRoute, hasDirectTitleSupport, shouldRetryWithStrongAfterFast } from "@/lib/rag-routing"; import { fetchRelatedDocumentMetadata, fetchRelatedDocuments } from "@/lib/document-enrichment"; import { boldHighYieldClinicalText, boldRagAnswerHighYieldText, rankAnswerEvidence } from "@/lib/answer-ranking"; @@ -3092,7 +3092,7 @@ async function attachPageVisualEvidence( page_number: image.page_number, storage_path: image.storage_path, caption: image.caption, - bbox: image.bbox as ChunkImage["bbox"], + bbox: normalizeImageBbox(image.bbox), image_type: image.image_type as ChunkImage["image_type"], searchable: image.searchable, clinical_relevance_score: image.clinical_relevance_score, diff --git a/tests/image-filtering.test.ts b/tests/image-filtering.test.ts index b6c774efc1..09af610341 100644 --- a/tests/image-filtering.test.ts +++ b/tests/image-filtering.test.ts @@ -5,6 +5,7 @@ import { classifiedImageSkipReason, isClinicalImageEvidence, lightweightPerceptualHash, + normalizeImageBbox, } from "../src/lib/image-filtering"; describe("smart image filtering", () => { @@ -42,6 +43,33 @@ describe("smart image filtering", () => { ).toBeNull(); }); + it("ignores object-shaped bbox jsonb instead of crashing", () => { + expect( + cheapImageSkipReason({ + bytesLength: 20_000, + imageHash: "obj", + seenHashes: new Set(), + image: { + sourceKind: "embedded", + width: 600, + height: 400, + bbox: { x0: 20, y0: 20, x1: 180, y1: 80 } as unknown as [number, number, number, number], + }, + }), + ).toBeNull(); + }); + + it("normalizes bbox jsonb to a four-number tuple or null", () => { + expect(normalizeImageBbox([20, 20, 180, 80])).toEqual([20, 20, 180, 80]); + expect(normalizeImageBbox(["20", "20", "180", "80"])).toEqual([20, 20, 180, 80]); + expect(normalizeImageBbox({ x0: 20, y0: 20, x1: 180, y1: 80 })).toBeNull(); + expect(normalizeImageBbox([20, 20, 180])).toBeNull(); + expect(normalizeImageBbox([20, 20, 180, "wide"])).toBeNull(); + expect(normalizeImageBbox([20, 20, 180, Number.NaN])).toBeNull(); + expect(normalizeImageBbox("20,20,180,80")).toBeNull(); + expect(normalizeImageBbox(null)).toBeNull(); + }); + it("keeps relevant clinical classifications searchable", () => { expect( classifiedImageSkipReason({