Skip to content
3 changes: 3 additions & 0 deletions .prettierignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
6 changes: 5 additions & 1 deletion src/app/api/documents/[id]/reindex/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -80,7 +80,11 @@ async function selectReindexRowsInPages<T>(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);
Expand Down
6 changes: 5 additions & 1 deletion src/app/api/documents/bulk/reindex/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,11 @@ async function selectRowsInPages<T>(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);
Expand Down
6 changes: 5 additions & 1 deletion src/app/api/ingestion/batches/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,11 @@ function batchesResponse(batches: BatchRow[], extra: Record<string, unknown> = {

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,
Expand Down
6 changes: 5 additions & 1 deletion src/app/api/ingestion/jobs/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,7 +48,11 @@ function jobsResponse(jobs: JobRow[], extra: Record<string, unknown> = {}) {

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,
Expand Down
5 changes: 2 additions & 3 deletions src/app/api/search/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
};
}

Expand Down
7 changes: 3 additions & 4 deletions src/components/document-viewer-lazy.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
});
22 changes: 15 additions & 7 deletions src/lib/document-naming.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 }>;
};
};
Expand DownExpand Up@@ -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")
Comment thread
BigSimmo marked this conversation as resolved.
.eq("owner_id", args.ownerId)
.limit(1000);
if (error) throw new Error(error.message);
Expand All@@ -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) {
Expand Down
16 changes: 14 additions & 2 deletions src/lib/image-filtering.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand Down
26 changes: 7 additions & 19 deletions src/lib/rag.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<SharedCacheMissReason> {
async function probeSharedCacheMissReason(reasonFromLookup?: SharedCacheMissReason): Promise<SharedCacheMissReason> {
if (reasonFromLookup) return reasonFromLookup;
try {
const supabase = createAdminClient();
Expand DownExpand Up@@ -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<Database["public"]["Tables"]["rag_queries"]["Insert"], "metadata"> & {
metadata?: Record<string, unknown>;
}
};

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<string, unknown>) : {};
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) {
Expand DownExpand Up@@ -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,
Expand Down
28 changes: 19 additions & 9 deletions tests/api-validation-contract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand DownExpand Up@@ -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" }) },
Expand DownExpand Up@@ -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." });

Expand All@@ -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." });

Expand DownExpand Up@@ -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);
Expand Down
24 changes: 24 additions & 0 deletions tests/document-naming.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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([
Expand Down
5 changes: 5 additions & 0 deletions tests/image-filtering.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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", () => {
Expand Down
5 changes: 4 additions & 1 deletion worker/main.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand Down
Loading