diff --git a/.prettierignore b/.prettierignore index 12f01abd77..fa1ce8a5ae 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,3 +12,6 @@ public/demo-documents/ .tmp-visual/ scratch/ .claude/worktrees/ +# Generated by `supabase gen types`; keep the generator's formatting so +# regeneration stays churn-free. +src/lib/supabase/database.types.ts diff --git a/src/app/api/documents/[id]/reindex/route.ts b/src/app/api/documents/[id]/reindex/route.ts index f726d03f0a..97e0a608e3 100644 --- a/src/app/api/documents/[id]/reindex/route.ts +++ b/src/app/api/documents/[id]/reindex/route.ts @@ -80,7 +80,11 @@ async function selectReindexRowsInPages(args: { for (let offset = 0; ; offset += reindexPageSize) { const dynamicSupabase = args.supabase as unknown as SupabaseClient; const query = args.searchableOnly - ? dynamicSupabase.from("document_images").select(args.select).eq("document_id", args.documentId).eq("searchable", true) + ? dynamicSupabase + .from("document_images") + .select(args.select) + .eq("document_id", args.documentId) + .eq("searchable", true) : dynamicSupabase.from(args.table).select(args.select).eq("document_id", args.documentId); const { data, error } = await query.range(offset, offset + reindexPageSize - 1); if (error) throw new Error(error.message); diff --git a/src/app/api/documents/bulk/reindex/route.ts b/src/app/api/documents/bulk/reindex/route.ts index b8450171b6..81f3367432 100644 --- a/src/app/api/documents/bulk/reindex/route.ts +++ b/src/app/api/documents/bulk/reindex/route.ts @@ -72,7 +72,11 @@ async function selectRowsInPages(args: { for (let offset = 0; ; offset += pageSize) { const dynamicSupabase = args.supabase as unknown as SupabaseClient; const query = args.searchableOnly - ? dynamicSupabase.from("document_images").select(args.select).eq("document_id", args.documentId).eq("searchable", true) + ? dynamicSupabase + .from("document_images") + .select(args.select) + .eq("document_id", args.documentId) + .eq("searchable", true) : dynamicSupabase.from(args.table).select(args.select).eq("document_id", args.documentId); const { data, error } = await query.range(offset, offset + pageSize - 1); if (error) throw new Error(error.message); diff --git a/src/app/api/ingestion/batches/route.ts b/src/app/api/ingestion/batches/route.ts index 82fbfdfe8e..ed6e93ecc8 100644 --- a/src/app/api/ingestion/batches/route.ts +++ b/src/app/api/ingestion/batches/route.ts @@ -46,7 +46,11 @@ function batchesResponse(batches: BatchRow[], extra: Record = { export async function GET(request: Request) { try { - const { limit, offset } = parseRequestQuery(request, ingestionBatchesQuerySchema, "Invalid ingestion batches query."); + const { limit, offset } = parseRequestQuery( + request, + ingestionBatchesQuerySchema, + "Invalid ingestion batches query.", + ); if (isDemoMode()) { return batchesResponse([], { demoMode: true, diff --git a/src/app/api/ingestion/jobs/route.ts b/src/app/api/ingestion/jobs/route.ts index d71ef0fd21..1505222b97 100644 --- a/src/app/api/ingestion/jobs/route.ts +++ b/src/app/api/ingestion/jobs/route.ts @@ -48,7 +48,11 @@ function jobsResponse(jobs: JobRow[], extra: Record = {}) { export async function GET(request: Request) { try { - const { batchId, limit, offset } = parseRequestQuery(request, ingestionJobsQuerySchema, "Invalid ingestion jobs query."); + const { batchId, limit, offset } = parseRequestQuery( + request, + ingestionJobsQuerySchema, + "Invalid ingestion jobs query.", + ); if (isDemoMode()) { return jobsResponse([], { demoMode: true, diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index ee680c6ff0..8c01596789 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -298,11 +298,10 @@ function compactSearchResults(query: string, results: SearchResult[]) { function searchDegradedModeSignal(telemetry?: { embedding_skip_reason?: string | null }) { const reason = telemetry?.embedding_skip_reason ?? null; const active = - reason === SOURCE_ONLY_EMBEDDING_SKIP_REASON || - (typeof reason === "string" && reason.startsWith("source_only_")); + reason === SOURCE_ONLY_EMBEDDING_SKIP_REASON || (typeof reason === "string" && reason.startsWith("source_only_")); return { active, - reason: active ? reason ?? "source_only" : null, + reason: active ? (reason ?? "source_only") : null, }; } diff --git a/src/components/document-viewer-lazy.tsx b/src/components/document-viewer-lazy.tsx index f910a6c2b7..26a39fa0ef 100644 --- a/src/components/document-viewer-lazy.tsx +++ b/src/components/document-viewer-lazy.tsx @@ -4,7 +4,6 @@ import dynamic from "next/dynamic"; // `ssr: false` requires a Client Component in the App Router; this wrapper // keeps the viewer bundle browser-only for the server-rendered document page. -export const DocumentViewerLazy = dynamic( - () => import("@/components/DocumentViewer").then((m) => m.DocumentViewer), - { ssr: false }, -); +export const DocumentViewerLazy = dynamic(() => import("@/components/DocumentViewer").then((m) => m.DocumentViewer), { + ssr: false, +}); diff --git a/src/lib/document-naming.ts b/src/lib/document-naming.ts index 4310674301..65151f7ff4 100644 --- a/src/lib/document-naming.ts +++ b/src/lib/document-naming.ts @@ -19,7 +19,10 @@ export type ExistingDocumentName = { export type DocumentNameSupabase = { from: (table: "documents") => { select: (columns: string) => { - eq: (column: "owner_id", value: string) => { + eq: ( + column: "owner_id", + value: string, + ) => { limit: (count: number) => PromiseLike<{ data: unknown[] | null; error: { message: string } | null }>; }; }; @@ -178,7 +181,7 @@ export async function planDocumentName(args: { if (!args.supabase) throw new Error("supabase client or existingDocs is required"); const { data, error } = await args.supabase .from("documents") - .select("id,title,file_name,content_hash") + .select("id,title,file_name,content_hash,metadata") .eq("owner_id", args.ownerId) .limit(1000); if (error) throw new Error(error.message); @@ -187,13 +190,18 @@ export async function planDocumentName(args: { const matching = documents.filter((document) => { if (args.contentHash && document.content_hash === args.contentHash) return false; const metadata = metadataRecord(document.metadata); - const groupKey = + // Match on both the stored group key and the current title: renames update the + // title while preserving metadata, so the stored key alone can be stale. + const storedGroupKey = typeof metadata.smart_title_group_key === "string" && metadata.smart_title_group_key.trim() ? metadata.smart_title_group_key - : document.title - ? documentTitleKey(document.title) - : ""; - return groupKey === duplicateGroupKey || documentTitleKey(document.file_name ?? "") === duplicateGroupKey; + : ""; + const titleGroupKey = document.title ? documentTitleKey(document.title) : ""; + return ( + storedGroupKey === duplicateGroupKey || + titleGroupKey === duplicateGroupKey || + documentTitleKey(document.file_name ?? "") === duplicateGroupKey + ); }); if (matching.length === 0) { diff --git a/src/lib/image-filtering.ts b/src/lib/image-filtering.ts index 72e75a957e..88816f270e 100644 --- a/src/lib/image-filtering.ts +++ b/src/lib/image-filtering.ts @@ -274,10 +274,22 @@ export function isClinicalImageEvidence(image: { return assessment.clinical_use_class === "clinical_evidence"; } +// Accept numbers and numeric strings, but not null/booleans/empty strings — bare +// Number(...) coercion would turn those into a plausible-looking 0 coordinate +// instead of rejecting the malformed row. +function bboxCoordinate(entry: unknown): number | null { + if (typeof entry === "number") return Number.isFinite(entry) ? entry : null; + if (typeof entry === "string" && entry.trim()) { + const numeric = Number(entry); + return Number.isFinite(numeric) ? numeric : null; + } + return null; +} + 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; + const coords = value.map(bboxCoordinate); + return coords.every((coord): coord is number => coord !== null) ? (coords as [number, number, number, number]) : null; } function bboxLooksLikeHeaderOrFooter(bbox: unknown) { diff --git a/src/lib/rag.ts b/src/lib/rag.ts index beb3169fc2..a9d449bf05 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1566,9 +1566,7 @@ async function getSharedCachedSearch( if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0) return null; const normalizedQuery = retrievalPlanCacheQuery(args, queryClass, queryVariants); const indexingVersion = await cacheIndexingVersion(args); - async function probeSharedCacheMissReason( - reasonFromLookup?: SharedCacheMissReason, - ): Promise { + async function probeSharedCacheMissReason(reasonFromLookup?: SharedCacheMissReason): Promise { if (reasonFromLookup) return reasonFromLookup; try { const supabase = createAdminClient(); @@ -1822,28 +1820,21 @@ export function invalidateRagCachesForDocumentMutation(ownerId: string) { invalidateAnonymousSharedRagCaches(); } -interface RagQueryInsert { - owner_id?: string | null; - query: string; - answer?: string | null; - source_chunk_ids?: string[] | null; - model?: string | null; +type RagQueryInsert = Omit & { metadata?: Record; -} +}; async function insertRagQuery(row: RagQueryInsert) { const supabase = createAdminClient(); // Redact potential-PHI raw query text centrally so every logRagQuery caller is // covered, and fold a stable hash + retention flag into metadata (RET-H4). const rawQuery = typeof row.query === "string" ? row.query : ""; - const existingMetadata = - row.metadata && typeof row.metadata === "object" ? (row.metadata as Record) : {}; const safeRow = { ...row, query: queryTextForStorage(rawQuery), - metadata: { ...existingMetadata, ...queryPrivacyMetadata(rawQuery) }, + metadata: { ...(row.metadata ?? {}), ...queryPrivacyMetadata(rawQuery) } as Json, }; - await supabase.from("rag_queries").insert(safeRow as Database["public"]["Tables"]["rag_queries"]["Insert"]); + await supabase.from("rag_queries").insert(safeRow); } async function logRagQuery(row: RagQueryInsert) { @@ -5202,16 +5193,13 @@ function cleanAnswerSectionHeading(heading: string, body: string) { function applyProviderLabels(answer: RagAnswer): RagAnswer { const inferredSourceOnlyFallback = - answer.routingMode === "extractive" || - /(?:^|;\s*)generation_fallback(?::|$)/i.test(answer.routingReason ?? ""); + answer.routingMode === "extractive" || /(?:^|;\s*)generation_fallback(?::|$)/i.test(answer.routingReason ?? ""); const answerQualityTier: RagAnswer["answerQualityTier"] = answer.answerQualityTier ?? (answer.modelUsed ? "model_synthesis" : inferredSourceOnlyFallback ? "source_only" : undefined); const fallbackReason = answer.fallbackReason ?? - (answerQualityTier === "source_only" - ? (fallbackReasonFromRouting(answer.routingReason) ?? "source_only") - : null); + (answerQualityTier === "source_only" ? (fallbackReasonFromRouting(answer.routingReason) ?? "source_only") : null); const degradedActive = answerQualityTier === "source_only"; return { ...answer, diff --git a/tests/api-validation-contract.test.ts b/tests/api-validation-contract.test.ts index d2c2fae827..4bdbf8834a 100644 --- a/tests/api-validation-contract.test.ts +++ b/tests/api-validation-contract.test.ts @@ -363,7 +363,9 @@ describe("API validation contracts", () => { const batchesRoute = await import("../src/app/api/ingestion/batches/route"); const jobsResponse = await jobsRoute.GET(authenticatedRequest("/api/jobs?limit=2&offset=1")); - const ingestionJobsResponse = await ingestionJobsRoute.GET(authenticatedRequest("/api/ingestion/jobs?limit=2&offset=1")); + const ingestionJobsResponse = await ingestionJobsRoute.GET( + authenticatedRequest("/api/ingestion/jobs?limit=2&offset=1"), + ); const batchesResponse = await batchesRoute.GET(authenticatedRequest("/api/ingestion/batches?limit=2&offset=1")); expect(jobsResponse.status).toBe(200); @@ -392,9 +394,12 @@ describe("API validation contracts", () => { const summarizeRoute = await import("../src/app/api/documents/[id]/summarize/route"); const labelsRoute = await import("../src/app/api/documents/[id]/labels/route"); - const retryResponse = await retryRoute.POST(authenticatedRequest("/api/ingestion/jobs/not-a-uuid/retry", { method: "POST" }), { - params: Promise.resolve({ id: "not-a-uuid" }), - }); + const retryResponse = await retryRoute.POST( + authenticatedRequest("/api/ingestion/jobs/not-a-uuid/retry", { method: "POST" }), + { + params: Promise.resolve({ id: "not-a-uuid" }), + }, + ); const summarizeResponse = await summarizeRoute.POST( authenticatedRequest("/api/documents/not-a-uuid/summarize", { method: "POST" }), { params: Promise.resolve({ id: "not-a-uuid" }) }, @@ -440,7 +445,9 @@ describe("API validation contracts", () => { const uploadRoute = await import("../src/app/api/upload/route"); const formData = new FormData(); formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" })); - const uploadResponse = await uploadRoute.POST(authenticatedRequest("/api/upload", { method: "POST", body: formData })); + const uploadResponse = await uploadRoute.POST( + authenticatedRequest("/api/upload", { method: "POST", body: formData }), + ); expect(uploadResponse.status).toBe(500); expect(await payload(uploadResponse)).toEqual({ error: "Request failed." }); @@ -452,9 +459,12 @@ describe("API validation contracts", () => { }); mockRuntime(retryClient); const retryRoute = await import("../src/app/api/ingestion/jobs/[id]/retry/route"); - const retryResponse = await retryRoute.POST(authenticatedRequest(`/api/ingestion/jobs/${documentId}/retry`, { method: "POST" }), { - params: Promise.resolve({ id: documentId }), - }); + const retryResponse = await retryRoute.POST( + authenticatedRequest(`/api/ingestion/jobs/${documentId}/retry`, { method: "POST" }), + { + params: Promise.resolve({ id: documentId }), + }, + ); expect(retryResponse.status).toBe(500); expect(await payload(retryResponse)).toEqual({ error: "Request failed." }); @@ -556,7 +566,7 @@ describe("API validation contracts", () => { authenticatedRequest("/api/upload", { method: "POST", headers: { "content-type": "multipart/form-data; boundary=broken" }, - body: "--broken\r\nContent-Disposition: form-data; name=\"file\"; filename=\"guideline.pdf\"\r\n\r\n%PDF-1.7", + body: '--broken\r\nContent-Disposition: form-data; name="file"; filename="guideline.pdf"\r\n\r\n%PDF-1.7', }), ); const body = await payload(response); diff --git a/tests/document-naming.test.ts b/tests/document-naming.test.ts index 452ff8d334..205206000b 100644 --- a/tests/document-naming.test.ts +++ b/tests/document-naming.test.ts @@ -62,6 +62,30 @@ describe("document naming", () => { }); }); + it("matches a renamed document by its current title when the stored group key is stale", async () => { + // Renames update title but preserve metadata, so smart_title_group_key can lag. + const plan = await planDocumentName({ + supabase: supabaseWithDocuments([ + { + id: "doc-1", + title: "Clozapine Prescribing", + file_name: "source.pdf", + content_hash: "hash-1", + metadata: { smart_title_group_key: "source" }, + }, + ]), + ownerId: "owner", + fileName: "clozapine_prescribing.pdf", + requestedTitle: "Clozapine Prescribing", + contentHash: "hash-2", + }); + + expect(plan).toMatchObject({ + title: "Clozapine Prescribing (Copy 2)", + duplicateReason: "same_title_or_filename", + }); + }); + it("prefers a version/date suffix from the uploaded filename when available", async () => { const plan = await planDocumentName({ supabase: supabaseWithDocuments([ diff --git a/tests/image-filtering.test.ts b/tests/image-filtering.test.ts index 09af610341..7ddeafc6c6 100644 --- a/tests/image-filtering.test.ts +++ b/tests/image-filtering.test.ts @@ -68,6 +68,11 @@ describe("smart image filtering", () => { expect(normalizeImageBbox([20, 20, 180, Number.NaN])).toBeNull(); expect(normalizeImageBbox("20,20,180,80")).toBeNull(); expect(normalizeImageBbox(null)).toBeNull(); + // Values that Number(...) would silently coerce to 0 must not become coordinates. + expect(normalizeImageBbox([null, 20, 180, 80])).toBeNull(); + expect(normalizeImageBbox(["", 20, 180, 80])).toBeNull(); + expect(normalizeImageBbox([false, 20, 180, 80])).toBeNull(); + expect(normalizeImageBbox([true, 20, 180, 80])).toBeNull(); }); it("keeps relevant clinical classifications searchable", () => { diff --git a/worker/main.ts b/worker/main.ts index a54582b4fc..3f1a8a8aeb 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -513,7 +513,10 @@ async function deleteStaleIndexGenerationRows(documentId: string, indexGeneratio .neq("index_generation_id", indexGenerationId); if (stale.error) throw supabaseStageError(`delete stale ${table}`, stale.error); if (!(await hasReplacementRows(table, true))) return; - const missing = await fromGenerationTable(table).delete().eq("document_id", documentId).is("index_generation_id", null); + const missing = await fromGenerationTable(table) + .delete() + .eq("document_id", documentId) + .is("index_generation_id", null); if (missing.error) throw supabaseStageError(`delete generationless ${table}`, missing.error); }; const deleteMetadataGenerationRows = async (table: string) => {