- {uploadTabs.map((tab) => {
- const active = uploadMobileTab === tab.id;
- const Icon = tab.icon;
- return (
- setUploadMobileTab(tab.id)}
- className={cn(
- "min-h-[56px] rounded-lg border px-2.5 py-2 text-left transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] active:translate-y-px",
- active
- ? "border-[color:var(--primary)] bg-[color:var(--primary-soft)] text-[color:var(--primary)] shadow-[var(--glow-soft)]"
- : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)]",
- )}
- >
-
-
- {tab.label}
-
-
- {tab.summary}
-
-
- );
- })}
-
-
-
-
- Developer setup status
-
-
- {showAuthPanel &&
}
-
-
+
-
- Indexing progress
-
-
+ {uploadTabs.map((tab) => {
+ const active = uploadMobileTab === tab.id;
+ const Icon = tab.icon;
+ return (
+
setUploadMobileTab(tab.id)}
+ className={cn(
+ "min-h-[56px] rounded-lg border px-2.5 py-2 text-left transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] active:translate-y-px",
+ active
+ ? "border-[color:var(--primary)] bg-[color:var(--primary-soft)] text-[color:var(--primary)] shadow-[var(--glow-soft)]"
+ : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)]",
+ )}
+ >
+
+
+ {tab.label}
+
+
+ {tab.summary}
+
+
+ );
+ })}
-
-
- Ingestion quality console
-
-
+
+
+
+ Developer setup status
+
+
+ {showAuthPanel &&
}
+
+
+
+ Clinical upload
+
+
+
+
+
+ Indexing progress
+
+
+
+
+
+ Ingestion quality console
+
+
+
-
) : null}
diff --git a/src/components/DashboardFloatingFab.tsx b/src/components/DashboardFloatingFab.tsx
index 98aa73a048..7b16a19440 100644
--- a/src/components/DashboardFloatingFab.tsx
+++ b/src/components/DashboardFloatingFab.tsx
@@ -92,12 +92,12 @@ export function DashboardFloatingFab() {
Copy link
setOpen(false)}
className={cn(floatingControl, "h-9 min-h-9 px-3 text-xs", !open && "hidden")}
>
- Tools
+ Applications
{copyNotice && (
diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx
index b20149abfe..93e6c2d248 100644
--- a/src/components/DocumentViewer.tsx
+++ b/src/components/DocumentViewer.tsx
@@ -2072,9 +2072,7 @@ export function DocumentViewer({
? `/?mode=documents&q=${encodeURIComponent(documentDisplayTitle(readyDocument))}`
: documentHomeHref;
const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUsePrivateApis;
- const summarizeTitle = canSummarizeDocument
- ? "Answer from this document"
- : "Load a source document before answering";
+ const summarizeTitle = canSummarizeDocument ? "Answer from this document" : "Load a source document before answering";
const selectedPage = pages.find((page) => page.page_number === initialPage) ?? pages[0];
const selectedChunk = chunkId ? chunks.find((chunk) => chunk.id === chunkId) : undefined;
const clinicalImages = images.filter(
diff --git a/src/components/clinical-dashboard/dashboard-shell.tsx b/src/components/clinical-dashboard/dashboard-shell.tsx
index b710deb450..5a9a573858 100644
--- a/src/components/clinical-dashboard/dashboard-shell.tsx
+++ b/src/components/clinical-dashboard/dashboard-shell.tsx
@@ -1,7 +1,7 @@
"use client";
import { BookOpen, ChevronDown, type LucideIcon } from "lucide-react";
-import { ReactNode, useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Sheet } from "@/components/ui/sheet";
import {
@@ -76,6 +76,14 @@ export function UtilityDrawer({
onOpenChange,
className,
mobileInline = false,
+ sheetHeaderLeading,
+ sheetTitleAccessory,
+ sheetDescriptionContent,
+ sheetHeaderActions,
+ sheetContentClassName,
+ sheetContentStyle,
+ sheetBodyClassName,
+ sheetDescription,
}: {
id?: string;
title: string;
@@ -88,10 +96,23 @@ export function UtilityDrawer({
onOpenChange?: (open: boolean) => void;
className?: string;
mobileInline?: boolean;
+ sheetHeaderLeading?: ReactNode;
+ sheetTitleAccessory?: ReactNode;
+ sheetDescriptionContent?: ReactNode;
+ sheetHeaderActions?: ReactNode;
+ sheetContentClassName?: string;
+ sheetContentStyle?: CSSProperties;
+ sheetBodyClassName?: string;
+ sheetDescription?: string | null;
}) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const [usesSheet, setUsesSheet] = useState(false);
+ const mobileTriggerRef = useRef(null);
const open = controlledOpen ?? uncontrolledOpen;
+ const triggerClassName = cn(
+ "flex min-h-[56px] w-full cursor-pointer list-none items-center justify-between gap-3 rounded-lg px-4 py-3 text-left transition motion-safe:duration-150 hover:bg-[color:var(--surface-subtle)]",
+ className,
+ );
const setOpen = useCallback(
(nextOpen: boolean) => {
if (controlledOpen === undefined) setUncontrolledOpen(nextOpen);
@@ -111,16 +132,12 @@ export function UtilityDrawer({
return (
<>
setOpen(true)}
aria-expanded={usesSheet ? open : undefined}
- className={cn(
- "group flex min-h-[56px] w-full cursor-pointer list-none items-center justify-between gap-3 rounded-lg px-4 py-3 text-left transition motion-safe:duration-150 hover:bg-[color:var(--surface-subtle)] sm:hidden",
- panelSubtle,
- mobileInline && "hidden",
- className,
- )}
+ className={cn("group sm:hidden", triggerClassName, mobileInline && "hidden")}
>
@@ -144,9 +161,9 @@ export function UtilityDrawer({
const nextOpen = event.currentTarget.open;
if (nextOpen !== open) setOpen(nextOpen);
}}
- className={cn("group", mobileInline ? "block" : "hidden sm:block", panelSubtle, className)}
+ className={cn("group overflow-hidden", mobileInline ? "block" : "hidden sm:block", panelSubtle)}
>
-
+
@@ -180,8 +197,17 @@ export function UtilityDrawer({
open={usesSheet && open && !mobileInline}
onClose={() => setOpen(false)}
title={title}
- description={mobileSummary ?? summary}
+ description={sheetDescription === undefined ? (mobileSummary ?? summary) : (sheetDescription ?? undefined)}
closeLabel={`Close ${title}`}
+ headerLeading={sheetHeaderLeading}
+ titleAccessory={sheetTitleAccessory}
+ descriptionContent={sheetDescriptionContent}
+ headerActions={sheetHeaderActions}
+ contentClassName={sheetContentClassName}
+ contentStyle={sheetContentStyle}
+ bodyClassName={sheetBodyClassName}
+ returnFocusRef={mobileTriggerRef}
+ portal
>
{children}
diff --git a/src/components/clinical-dashboard/display-text.ts b/src/components/clinical-dashboard/display-text.ts
index d1b54d3ade..11a8af2bf3 100644
--- a/src/components/clinical-dashboard/display-text.ts
+++ b/src/components/clinical-dashboard/display-text.ts
@@ -3,6 +3,7 @@ import {
sourceTextForClinicalProse,
sourceTextForClinicalProsePreservingBreaks,
} from "@/lib/source-text-sanitizer";
+import { polishClinicalAnswerProse } from "@/lib/rag-answer-text";
import type { SearchResult } from "@/lib/types";
const displayJsonArtifactPattern =
@@ -110,7 +111,7 @@ export function compactTableFact(fact: NonNullable[
}
export function sanitizeAnswerDisplayText(value: string, options: DisplayTextSanitizeOptions = {}) {
- const normalized = sourceTextForClinicalProsePreservingBreaks(value).trim();
+ const normalized = polishClinicalAnswerProse(sourceTextForClinicalProsePreservingBreaks(value)).trim();
if (!normalized) return "";
const artifactStart = normalizeDisplayText(normalized).search(
/\{\s*"(?:answer|heading|body|grounded|confidence|citations?|answerSections?|citation_chunk_ids|source_chunk_ids|chunk_id|conflictsOrGaps|quoteCards?)\s*:/i,
diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx
index 46e1cb0558..d180b8e27b 100644
--- a/src/components/clinical-dashboard/document-search-results.tsx
+++ b/src/components/clinical-dashboard/document-search-results.tsx
@@ -1,20 +1,22 @@
"use client";
-import Link from "next/link";
import { useMemo, useState } from "react";
import {
AlertCircle,
+ BookOpen,
ChevronDown,
+ Clock3,
+ ExternalLink,
+ FileImage,
FileText,
Filter,
+ FolderOpen,
ListChecks,
ShieldAlert,
SlidersHorizontal,
Sparkles,
- Star,
Tag,
Target,
- TrendingUp,
X,
type LucideIcon,
} from "lucide-react";
@@ -27,7 +29,6 @@ import {
DocumentActionLink,
DocumentBadge,
DocumentFileTile,
- DocumentMetaRow,
documentFileKind,
documentTileTone,
} from "@/components/clinical-dashboard/document-ui";
@@ -190,15 +191,36 @@ function filterMatchesByResultType(matches: DocumentMatch[], filter: ResultTypeF
return matches;
}
-function compactEvidenceBadges(document: DocumentMatch) {
- return [
- document.file_name.toLowerCase().endsWith(".pdf")
- ? "PDF"
- : document.file_name.split(".").pop()?.toUpperCase() || "DOC",
- documentPageLabel(document),
- document.tableCount > 0 ? `${document.tableCount} table${document.tableCount === 1 ? "" : "s"}` : "",
- document.imageCount > 0 ? `${document.imageCount} image${document.imageCount === 1 ? "" : "s"}` : "",
- ].filter(Boolean);
+function compactEvidenceBadges(document: DocumentMatch): Array<{
+ label: string;
+ icon: LucideIcon;
+ variant?: "neutral" | "relevant";
+}> {
+ const extension = document.file_name.toLowerCase().endsWith(".pdf")
+ ? "PDF"
+ : document.file_name.split(".").pop()?.toUpperCase() || "DOC";
+ const badges: Array<{ label: string; icon: LucideIcon; variant?: "neutral" | "relevant" }> = [
+ { label: extension, icon: FileText },
+ { label: documentPageLabel(document), icon: BookOpen },
+ ];
+
+ if (document.tableCount > 0) {
+ badges.push({
+ label: `${document.tableCount} table${document.tableCount === 1 ? "" : "s"}`,
+ icon: ListChecks,
+ variant: "relevant",
+ });
+ }
+
+ if (document.imageCount > 0) {
+ badges.push({
+ label: `${document.imageCount} image${document.imageCount === 1 ? "" : "s"}`,
+ icon: FileImage,
+ variant: "relevant",
+ });
+ }
+
+ return badges;
}
function compactMatchReason(document: DocumentMatch) {
@@ -246,6 +268,21 @@ function relevanceTone(document: DocumentMatch) {
return { label: "Relevant", short: "Relevant", detail: `${percent}% nearby` };
}
+function sourceSupportLabel(document: DocumentMatch) {
+ const verdict = document.relevance?.verdict as string | undefined;
+ if (verdict === "direct") return "Direct source support";
+ if (verdict === "partial") return "Partial source support";
+ if (verdict === "nearby") return "Nearby source support";
+ return "Source match";
+}
+
+function contextualOpenLabel(document: DocumentMatch) {
+ if (document.tableCount > 0) return "Open table";
+ if (document.imageCount > 0) return "Open image";
+ if (document.file_name.toLowerCase().endsWith(".pdf")) return "Open PDF";
+ return "Open source";
+}
+
function documentOpenHref(document: DocumentMatch) {
const params = new URLSearchParams();
params.set("page", String(document.bestPages[0] ?? 1));
@@ -254,27 +291,118 @@ function documentOpenHref(document: DocumentMatch) {
return `/documents/${document.document_id}?${params.toString()}`;
}
-function DocumentSearchHome({ documentCount }: { documentCount: number }) {
+function WhyThisResultDisclosure({ document }: { document: DocumentMatch }) {
+ const relevanceDisplay = relevanceTone(document);
+ const matchedTerms = document.relevance?.matchedTerms?.slice(0, 5) ?? [];
+ const missingTerms = document.relevance?.missingTerms?.slice(0, 4) ?? [];
+ const evidenceTypes = [
+ document.tableCount > 0 ? `${document.tableCount} table${document.tableCount === 1 ? "" : "s"}` : "",
+ document.imageCount > 0 ? `${document.imageCount} image${document.imageCount === 1 ? "" : "s"}` : "",
+ document.file_name.toLowerCase().endsWith(".pdf") ? "PDF source" : "",
+ ].filter(Boolean);
+
return (
-
-
-
-
+
+
+ Why this result?
+
+
+
+
+ {sourceSupportLabel(document)}
+ {relevanceDisplay.detail}
+
+
{compactMatchReason(document)}
+ {matchedTerms.length ?
Matched terms: {matchedTerms.join(", ")}
: null}
+ {missingTerms.length ?
Not directly found: {missingTerms.join(", ")}
: null}
+ {evidenceTypes.length ?
Evidence available: {evidenceTypes.join(", ")}
: null}
+
+
+ );
+}
+
+function DocumentSearchHome({
+ documentCount,
+ onOpenRecentDocuments,
+ onOpenLibrary,
+ onOpenSourcePdf,
+}: {
+ documentCount: number;
+ onOpenRecentDocuments: () => void;
+ onOpenLibrary: () => void;
+ onOpenSourcePdf: () => void;
+}) {
+ const startItems = [
+ {
+ label: "Recent documents",
+ description: "Continue reading where you left off",
+ icon: Clock3,
+ action: onOpenRecentDocuments,
+ },
+ {
+ label: "Browse library",
+ description: "Search all indexed sources",
+ icon: FolderOpen,
+ action: onOpenLibrary,
+ },
+ {
+ label: "Open a source PDF",
+ description: "View original source files",
+ icon: ExternalLink,
+ action: onOpenSourcePdf,
+ },
+ ];
+ return (
+
+
+
+
-
- Documents
-
-
- Find guidelines, policies, forms, and source PDFs.
-
-
-
- {documentCount > 0
- ? `${documentCount.toLocaleString()} source${documentCount === 1 ? "" : "s"} indexed`
- : "No indexed sources"}
-
+
+
Documents
+
+ Open, browse, and continue reading your clinical sources.
+
+
+
+ {startItems.map((item) => {
+ const Icon = item.icon;
+ return (
+
+
+
+
+
+
+ {item.label}
+
+ {item.description}
+
+
+
+ );
+ })}
+
+
+
+ {documentCount.toLocaleString()} indexed source{documentCount === 1 ? "" : "s"}
+
);
}
@@ -315,6 +443,63 @@ function SearchResultsHeader({ resultLabel, trimmedQuery }: { resultLabel: strin
);
}
+function DocumentResultsOverview({
+ documentCount,
+ displayedCount,
+ matchCount,
+ activeFacetCount,
+ trimmedQuery,
+ onOpenLibrary,
+}: {
+ documentCount: number;
+ displayedCount: number;
+ matchCount: number;
+ activeFacetCount: number;
+ trimmedQuery: string;
+ onOpenLibrary: () => void;
+}) {
+ return (
+
+
+
Documents overview
+
+
+ {documentCount.toLocaleString()} indexed
+
+
+ {matchCount.toLocaleString()} match{matchCount === 1 ? "" : "es"}
+
+ {activeFacetCount > 0 ? (
+
+ {displayedCount.toLocaleString()} after filters
+
+ ) : null}
+ {trimmedQuery ? (
+
+ {trimmedQuery}
+
+ ) : null}
+
+
+
+
+ Browse library
+
+
+ );
+}
+
export function MatchExplanationChips({ source }: { source: SearchResult }) {
const explanation = source.match_explanation;
const reasons = explanation?.reasons?.length
@@ -360,6 +545,9 @@ export function DocumentSearchResultsPanel({
facets: _facets,
onScopeDocument,
onAnswerFromDocument,
+ onOpenRecentDocuments,
+ onOpenLibrary,
+ onOpenSourcePdf,
onTagSearch,
}: {
matches: DocumentMatch[];
@@ -373,6 +561,9 @@ export function DocumentSearchResultsPanel({
facets?: SearchFacets | null;
onScopeDocument: (documentId: string) => void;
onAnswerFromDocument: (documentId: string) => void;
+ onOpenRecentDocuments: () => void;
+ onOpenLibrary: () => void;
+ onOpenSourcePdf: () => void;
onTagSearch: (tag: SmartDocumentTag | SmartDocumentTagFacet) => void;
}) {
void _facets;
@@ -456,10 +647,23 @@ export function DocumentSearchResultsPanel({
) : (
-
+
)
) : (
<>
+
{resultTabs.length > 1 ? (
{resultTabs.map((tab) => {
@@ -506,8 +710,8 @@ export function DocumentSearchResultsPanel({
{displayedMatches.map((document, index) => {
const evidenceBadges = compactEvidenceBadges(document);
const relevanceDisplay = relevanceTone(document);
- const fileKind = documentFileKind(document.file_name, "DOC");
const relevanceVariant = relevanceDisplay.short === "Relevant" ? "relevant" : "high";
+ const fileKind = documentFileKind(document.file_name, "DOC");
const summaryText = cleanDocumentCardSummary(document.summarySnippet || compactMatchReason(document));
const openHref = documentOpenHref(document);
return (
@@ -515,82 +719,84 @@ export function DocumentSearchResultsPanel({
key={document.document_id}
className={cn(
sourceCard,
- "relative overflow-hidden p-0 shadow-[0_10px_24px_rgb(15_27_45_/_5%)]",
- index === 0 && "border-l-4 border-l-[color:var(--clinical-chat-teal)]",
+ "relative overflow-visible p-0 shadow-[0_8px_18px_rgb(15_27_45_/_4%)] transition hover:border-[color:var(--clinical-chat-teal-border)] hover:shadow-[0_14px_32px_rgb(15_27_45_/_7%)]",
+ index === 0 && "ring-1 ring-[color:var(--clinical-chat-teal)]/15",
)}
>
-
-
+
+
-
- {documentKindLabel(document)}
+
+ {documentKindLabel(document)}
+ {index === 0 ? (
+ <>
+
+ Best match
+ >
+ ) : null}
-
{documentDisplayTitle(document)}
-
-
-
- {index === 0 ? (
-
- Best match
-
- ) : null}
-
- {relevanceDisplay.short}
- , {relevanceDisplay.detail}
-
+
-
- {index === 0 ? (
-
- Best match
-
- ) : null}
+
{relevanceDisplay.short}
, {relevanceDisplay.detail}
+ {evidenceBadges.map((badge) => (
+
+ {badge.label}
+
+ ))}
-
- {evidenceBadges.length ?
{evidenceBadges.join(", ")} : null}
-
+ {evidenceBadges.length ? (
+ {evidenceBadges.map((badge) => badge.label).join(", ")}
+ ) : null}
+
-
+
+
- Open
+ {contextualOpenLabel(document)}
onScopeDocument(document.document_id)}
icon={Filter}
- className="min-h-12 border-r border-[color:var(--border)] text-sm text-[color:var(--text)]"
+ className="min-h-11 rounded-lg px-2.5 text-xs text-[color:var(--text)]"
aria-label={`Scope search to ${document.title}`}
>
Scope
@@ -598,7 +804,7 @@ export function DocumentSearchResultsPanel({
onAnswerFromDocument(document.document_id)}
icon={Sparkles}
- className="min-h-12 text-sm text-[color:var(--clinical-chat-teal)] hover:bg-[color:var(--clinical-chat-teal-soft)]"
+ className="ml-auto min-h-11 rounded-lg px-2.5 text-xs text-[color:var(--clinical-chat-teal)] hover:bg-[color:var(--clinical-chat-teal-soft)]"
aria-label={`Answer from ${document.title}`}
>
Answer
diff --git a/src/components/clinical-dashboard/document-ui.tsx b/src/components/clinical-dashboard/document-ui.tsx
index ee3e573876..565c7dca98 100644
--- a/src/components/clinical-dashboard/document-ui.tsx
+++ b/src/components/clinical-dashboard/document-ui.tsx
@@ -11,9 +11,9 @@ export type DocumentTileTone = "teal" | "info";
const badgeStyles: Record = {
best: "border-[color:var(--clinical-chat-teal)]/20 bg-[color:var(--clinical-chat-teal-soft)] text-[color:var(--clinical-chat-teal)] shadow-[var(--shadow-inset)]",
- high: "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]",
+ high: "border-[color:var(--clinical-chat-teal)]/18 bg-[color:var(--surface-raised)] text-[color:var(--clinical-chat-teal)] shadow-[var(--shadow-inset)]",
relevant: "border-[color:var(--info)]/15 bg-[color:var(--info-soft)]/70 text-[color:var(--info)]",
- neutral: "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]",
+ neutral: "border-[color:var(--border-lux)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]",
};
const tileStyles: Record = {
diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx
index ddccf7005d..68089c97d8 100644
--- a/src/components/clinical-dashboard/master-search-header.tsx
+++ b/src/components/clinical-dashboard/master-search-header.tsx
@@ -156,8 +156,7 @@ export function MasterSearchHeader({
const selectedSearchable = isSearchableAppMode(searchMode);
const scopeIsPlaceholder = scopeVariant === "placeholder";
const canRunLocalSearch = selectedSearch.kind === "favourites" || selectedSearch.kind === "tools";
- const canAsk =
- trimmedQuery.length >= 1 && !loading && selectedSearchable && (realDataReady || canRunLocalSearch);
+ const canAsk = trimmedQuery.length >= 1 && !loading && selectedSearchable && (realDataReady || canRunLocalSearch);
const indexedDocumentTotal = documentTotal ?? documents.length;
const hasUnloadedDocuments = indexedDocumentTotal > documents.length;
const loadedScopeSummary = hasUnloadedDocuments
@@ -640,8 +639,7 @@ export function MasterSearchHeader({
ref={modeMenuRef}
className={cn(
"relative z-40 mx-auto sm:mx-0",
- modeAlignment === "center" &&
- "absolute left-1/2 top-1/2 mx-0 -translate-x-1/2 -translate-y-1/2",
+ modeAlignment === "center" && "absolute left-1/2 top-1/2 mx-0 -translate-x-1/2 -translate-y-1/2",
)}
>
+
{rowContent}
);
@@ -641,11 +637,7 @@ function MedicationResults({
}
return (
-
+
{cardContent}
);
@@ -1181,7 +1173,9 @@ export function AcamprosateMedicationPage() {
Medication search
Search
- Clinical KB
+
+ Clinical KB
+
diff --git a/src/components/settings-search-mockups/index.ts b/src/components/settings-search-mockups/index.ts
new file mode 100644
index 0000000000..abe8964a19
--- /dev/null
+++ b/src/components/settings-search-mockups/index.ts
@@ -0,0 +1,2 @@
+export { SettingsSearchMockupPage } from "./settings-search-mockup-page";
+export type { SettingsSearchMockupVariant } from "./settings-search-mockup-page";
diff --git a/src/components/settings-search-mockups/settings-search-mockup-page.tsx b/src/components/settings-search-mockups/settings-search-mockup-page.tsx
new file mode 100644
index 0000000000..de43e8bcf9
--- /dev/null
+++ b/src/components/settings-search-mockups/settings-search-mockup-page.tsx
@@ -0,0 +1,630 @@
+import {
+ Bell,
+ ChevronDown,
+ ChevronRight,
+ CircleUserRound,
+ FileText,
+ Globe2,
+ Keyboard,
+ LockKeyhole,
+ MessageSquare,
+ Palette,
+ Settings,
+ ShieldCheck,
+ SlidersHorizontal,
+ Sparkles,
+ Stethoscope,
+ UserRound,
+ X,
+ type LucideIcon,
+} from "lucide-react";
+import type { ReactNode } from "react";
+
+import { appBackdrop, cn } from "@/components/ui-primitives";
+
+export type SettingsSearchMockupVariant = "general" | "clinical" | "privacy";
+
+type SettingsRow = {
+ label: string;
+ description?: string;
+ value?: string;
+ enabled?: boolean;
+ icon?: LucideIcon;
+};
+
+type SettingsSection = {
+ title: string;
+ rows: SettingsRow[];
+};
+
+type Concept = {
+ eyebrow: string;
+ title: string;
+ subtitle: string;
+ activeNav: string;
+ summary: SettingsRow[];
+ sections: SettingsSection[];
+ phoneSections: SettingsSection[];
+};
+
+const navItems: Array<{ label: string; icon: LucideIcon }> = [
+ { label: "General", icon: Settings },
+ { label: "Clinical defaults", icon: Stethoscope },
+ { label: "Personalization", icon: Sparkles },
+ { label: "Notifications", icon: Bell },
+ { label: "Privacy", icon: ShieldCheck },
+ { label: "Security", icon: LockKeyhole },
+ { label: "Account", icon: CircleUserRound },
+ { label: "Keyboard", icon: Keyboard },
+];
+
+const concepts: Record = {
+ general: {
+ eyebrow: "Concept 01",
+ title: "Account hub",
+ subtitle:
+ "The refined default direction: profile, app behaviour, and clinical preferences in one calm settings surface.",
+ activeNav: "Account",
+ summary: [
+ { label: "Profile", value: "Clinical", icon: UserRound },
+ { label: "Default mode", value: "Ask", icon: MessageSquare },
+ { label: "Clinical setup", value: "WA", icon: Stethoscope },
+ ],
+ sections: [
+ {
+ title: "Account",
+ rows: [
+ { label: "Profile", value: "Dr Simpson", icon: UserRound },
+ { label: "Clinical role", value: "Consultant psychiatrist", icon: Stethoscope },
+ ],
+ },
+ {
+ title: "App",
+ rows: [
+ { label: "Appearance", value: "System", icon: Palette },
+ { label: "Interface density", value: "Comfortable", icon: Settings },
+ { label: "Default landing view", value: "Ask", icon: MessageSquare },
+ ],
+ },
+ {
+ title: "Clinical KB",
+ rows: [
+ { label: "Answer style", value: "Conservative", icon: SlidersHorizontal },
+ { label: "Citation display", value: "Inline", icon: FileText },
+ ],
+ },
+ ],
+ phoneSections: [
+ {
+ title: "Account",
+ rows: [
+ { label: "Profile", value: "Dr Simpson", icon: UserRound },
+ { label: "Clinical role", value: "Psychiatrist", icon: Stethoscope },
+ ],
+ },
+ {
+ title: "App",
+ rows: [
+ { label: "Appearance", value: "System", icon: Palette },
+ { label: "Interface density", value: "Comfort", icon: Settings },
+ { label: "Default mode", value: "Ask", icon: MessageSquare },
+ ],
+ },
+ {
+ title: "Clinical KB",
+ rows: [
+ { label: "Answer style", value: "Conservative", icon: SlidersHorizontal },
+ { label: "Citation display", value: "Inline", icon: FileText },
+ ],
+ },
+ ],
+ },
+ clinical: {
+ eyebrow: "Concept 02",
+ title: "Clinical-ready account hub",
+ subtitle:
+ "A stronger account hub for clinicians who want role, region, evidence, and app defaults visible together.",
+ activeNav: "Account",
+ summary: [
+ { label: "Role", value: "Psychiatry", icon: Stethoscope },
+ { label: "Answer style", value: "Conservative", icon: SlidersHorizontal },
+ { label: "Evidence", value: "Current first", icon: FileText },
+ ],
+ sections: [
+ {
+ title: "Account",
+ rows: [
+ { label: "Profile", value: "Dr Simpson", icon: UserRound },
+ { label: "Clinical role", value: "Consultant psychiatrist", icon: Stethoscope },
+ { label: "Jurisdiction", value: "Western Australia", icon: Globe2 },
+ ],
+ },
+ {
+ title: "Clinical defaults",
+ rows: [
+ { label: "Answer style", value: "Conservative", icon: SlidersHorizontal },
+ { label: "Citation detail", value: "Inline and expandable", icon: FileText },
+ { label: "Source preference", value: "Current guidance first" },
+ {
+ label: "Safety and monitoring prompts",
+ description: "Surface contraindication, baseline test, and follow-up monitoring prompts.",
+ enabled: true,
+ },
+ ],
+ },
+ ],
+ phoneSections: [
+ {
+ title: "Account",
+ rows: [
+ { label: "Profile", value: "Dr Simpson", icon: UserRound },
+ { label: "Clinical role", value: "Psychiatry", icon: Stethoscope },
+ { label: "Jurisdiction", value: "WA", icon: Globe2 },
+ ],
+ },
+ {
+ title: "Clinical KB",
+ rows: [
+ { label: "Answer style", value: "Conservative", icon: SlidersHorizontal },
+ { label: "Evidence", value: "Current first", icon: FileText },
+ { label: "Safety prompts", value: "On", icon: ShieldCheck },
+ ],
+ },
+ {
+ title: "App",
+ rows: [{ label: "Default mode", value: "Ask", icon: MessageSquare }],
+ },
+ ],
+ },
+ privacy: {
+ eyebrow: "Concept 03",
+ title: "Private account hub",
+ subtitle:
+ "A privacy-led account hub with PHI-safe defaults, app lock, hidden previews, and clinical personalisation.",
+ activeNav: "Account",
+ summary: [
+ { label: "Privacy", value: "No PHI", icon: ShieldCheck },
+ { label: "App lock", value: "On", icon: LockKeyhole },
+ { label: "Previews", value: "Hidden", icon: Bell },
+ ],
+ sections: [
+ {
+ title: "Account",
+ rows: [
+ { label: "Profile", value: "Dr Simpson", icon: UserRound },
+ { label: "Clinical role", value: "Consultant psychiatrist", icon: Stethoscope },
+ { label: "Jurisdiction", value: "Western Australia", icon: Globe2 },
+ ],
+ },
+ {
+ title: "Privacy",
+ rows: [
+ {
+ label: "No PHI reminders",
+ description: "Warn before storing patient identifiers in saved prompts or notes.",
+ enabled: true,
+ icon: ShieldCheck,
+ },
+ {
+ label: "Topic-only history",
+ description: "Show recent work by topic and guideline, not patient details.",
+ enabled: true,
+ },
+ {
+ label: "Citation privacy",
+ description: "Keep source trails visible without recording patient-identifying details.",
+ enabled: true,
+ },
+ {
+ label: "Hide notification previews",
+ description: "Keep clinical content hidden on the lock screen and notification tray.",
+ enabled: true,
+ },
+ ],
+ },
+ {
+ title: "Security",
+ rows: [
+ { label: "Require app lock", value: "After 5 minutes", icon: LockKeyhole },
+ { label: "Active sessions", value: "This device", icon: LockKeyhole },
+ ],
+ },
+ ],
+ phoneSections: [
+ {
+ title: "Account",
+ rows: [
+ { label: "Profile", value: "Dr Simpson", icon: UserRound },
+ { label: "Clinical role", value: "Psychiatry", icon: Stethoscope },
+ ],
+ },
+ {
+ title: "Privacy",
+ rows: [
+ { label: "No PHI reminders", value: "On", icon: ShieldCheck },
+ { label: "Topic-only history", value: "On", icon: MessageSquare },
+ { label: "Previews", value: "Hidden", icon: Bell },
+ ],
+ },
+ {
+ title: "Security",
+ rows: [
+ { label: "App lock", value: "5 min", icon: LockKeyhole },
+ { label: "Security", value: "Protected", icon: LockKeyhole },
+ ],
+ },
+ ],
+ },
+};
+
+function Toggle({ enabled }: { enabled?: boolean }) {
+ return (
+
+
+
+ );
+}
+
+function IconFrame({ icon: Icon, active = false }: { icon: LucideIcon; active?: boolean }) {
+ return (
+
+
+
+ );
+}
+
+function DesktopNav({ active }: { active: string }) {
+ return (
+
+
+
+
+ {navItems.map(({ label, icon: Icon }) => {
+ const selected = label === active;
+ return (
+
+
+ {label}
+
+ );
+ })}
+
+ );
+}
+
+function SummaryTile({ row }: { row: SettingsRow }) {
+ const Icon = row.icon ?? Settings;
+
+ return (
+
+
+
+ {row.label}
+ {row.value}
+
+
+ );
+}
+
+function StatusChip({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function DesktopProfileStrip() {
+ return (
+
+
+ DS
+
+
+
+ Dr Simpson
+
+ Consultant psychiatrist, Western Australia
+
+
+
+ Private
+ No PHI
+
+
+ );
+}
+
+function SettingRow({ row }: { row: SettingsRow }) {
+ const Icon = row.icon;
+ return (
+
+ {Icon ? : }
+
+ {row.label}
+ {row.description ? (
+
+ {row.description}
+
+ ) : null}
+
+ {typeof row.enabled === "boolean" ? (
+
+ ) : (
+
+ {row.value}
+
+
+ )}
+
+ );
+}
+
+function DesktopModal({ concept }: { concept: Concept }) {
+ return (
+
+
+
+
+
+
+
+ {concept.eyebrow}
+
+
{concept.activeNav}
+
+
+ Private workspace
+
+
+
+
+
+
+ {concept.summary.map((row) => (
+
+ ))}
+
+
+
+ {concept.sections.map((section, index) => (
+
0 && "border-t border-[color:var(--border)] pt-4")}>
+
+ {section.title}
+
+
+ {section.rows.map((row) => (
+
+ ))}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+function PhoneStatusBar() {
+ return (
+
+ 9:41
+
+
+
+
+
+ );
+}
+
+function PhoneBackdrop() {
+ return (
+
+
+
+ {[0, 1, 2].map((item) => (
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
+function PhoneProfileRow() {
+ return (
+
+
+ DS
+
+
+
+ Dr Simpson
+
+ Consultant psychiatrist, WA
+
+
+
+
+ );
+}
+
+function PhoneClinicalStatus() {
+ return (
+
+ {["Private", "WA", "No PHI"].map((item, index) => (
+
+ {item}
+
+ ))}
+
+ );
+}
+
+function PhoneSettingsSection({ section }: { section: SettingsSection }) {
+ return (
+
+
+ {section.title}
+
+
+ {section.rows.map((row, index) => {
+ const Icon = row.icon ?? Settings;
+ return (
+
+
+
+ {row.label}
+
+
+ {row.value}
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+function PhoneSheet({ concept }: { concept: Concept }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {concept.phoneSections.map((section) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function BoardShell({ children, concept }: { children: ReactNode; concept: Concept }) {
+ return (
+ <>
+
+
+
+
+
+
+ {concept.eyebrow}
+
+
{concept.title}
+
{concept.subtitle}
+
+
+ Desktop + iPhone settings popup
+
+
+
{children}
+
+
+ >
+ );
+}
+
+export function SettingsSearchMockupPage({ variant }: { variant: SettingsSearchMockupVariant }) {
+ const concept = concepts[variant];
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx
index 6bc8ee5b1a..823ce6e90b 100644
--- a/src/components/ui/sheet.tsx
+++ b/src/components/ui/sheet.tsx
@@ -1,6 +1,7 @@
"use client";
-import { useEffect, useId, useRef, type ReactNode, type RefObject } from "react";
+import { useEffect, useId, useRef, type CSSProperties, type ReactNode, type RefObject } from "react";
+import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { cn, toolbarButton } from "@/components/ui-primitives";
@@ -22,8 +23,15 @@ export function Sheet({
labelledBy,
initialFocusRef,
returnFocusRef,
+ headerLeading,
+ titleAccessory,
+ descriptionContent,
+ headerActions,
contentClassName,
+ contentStyle,
+ bodyClassName,
placement = "default",
+ portal = false,
}: {
open: boolean;
onClose: () => void;
@@ -35,8 +43,15 @@ export function Sheet({
labelledBy?: string;
initialFocusRef?: RefObject;
returnFocusRef?: RefObject;
+ headerLeading?: ReactNode;
+ titleAccessory?: ReactNode;
+ descriptionContent?: ReactNode;
+ headerActions?: ReactNode;
contentClassName?: string;
+ contentStyle?: CSSProperties;
+ bodyClassName?: string;
placement?: "default" | "left";
+ portal?: boolean;
}) {
const panelRef = useRef(null);
const closeRef = useRef(null);
@@ -97,14 +112,14 @@ export function Sheet({
const resolvedLabelledBy = labelledBy ?? (title ? titleId : undefined);
- return (
+ const sheet = (
{
+ onPointerDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
@@ -113,8 +128,9 @@ export function Sheet({
role="dialog"
aria-modal="true"
aria-labelledby={resolvedLabelledBy}
- aria-describedby={description ? descId : undefined}
- onMouseDown={(event) => event.stopPropagation()}
+ aria-describedby={description || descriptionContent ? descId : undefined}
+ onPointerDown={(event) => event.stopPropagation()}
+ style={contentStyle}
className={cn(
"flex min-w-0 w-full flex-col overflow-hidden border border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text)] shadow-[var(--shadow-elevated)] pb-safe",
"transition duration-200 motion-reduce:transition-none sm:duration-150",
@@ -133,25 +149,47 @@ export function Sheet({
aria-hidden
/>
{title ? (
-
-
-
- {title}
-
- {description ? (
-
- {description}
-
- ) : null}
+
+
+ {headerLeading ?
{headerLeading}
: null}
+
+
+
+ {title}
+
+ {titleAccessory}
+
+ {descriptionContent ? (
+
+ {descriptionContent}
+
+ ) : description ? (
+
+ {description}
+
+ ) : null}
+
+
+
+ {headerActions}
+
+
+
-
-
-
) : null}
-
{children}
+
+ {children}
+
{footer ?
{footer}
: null}
);
+
+ if (portal) {
+ if (typeof document === "undefined") return null;
+ return createPortal(sheet, document.body);
+ }
+
+ return sheet;
}
diff --git a/src/components/user-home-profile/index.ts b/src/components/user-home-profile/index.ts
deleted file mode 100644
index 15768b063a..0000000000
--- a/src/components/user-home-profile/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default as UserHomeProfilePage } from "./user-home-profile-page";
diff --git a/src/components/user-home-profile/user-home-profile-page.tsx b/src/components/user-home-profile/user-home-profile-page.tsx
deleted file mode 100644
index 265d718b75..0000000000
--- a/src/components/user-home-profile/user-home-profile-page.tsx
+++ /dev/null
@@ -1,762 +0,0 @@
-import {
- Bell,
- BookOpen,
- BookOpenCheck,
- ChevronRight,
- ClipboardCheck,
- FileText,
- HeartPulse,
- Home,
- KeyRound,
- LogOut,
- MessageSquare,
- Search,
- Settings,
- ShieldCheck,
- SlidersHorizontal,
- Stethoscope,
- UserRound,
- type LucideIcon,
-} from "lucide-react";
-
-import { cn } from "@/components/ui-primitives";
-
-const primaryActions: Array<{
- title: string;
- body: string;
- icon: LucideIcon;
- tone: "primary" | "neutral";
-}> = [
- {
- title: "Ask",
- body: "Clinical question",
- icon: MessageSquare,
- tone: "primary",
- },
- {
- title: "Sources",
- body: "Guidelines and evidence",
- icon: BookOpen,
- tone: "neutral",
- },
- {
- title: "Review",
- body: "Source queue",
- icon: ClipboardCheck,
- tone: "neutral",
- },
- {
- title: "Settings",
- body: "Defaults",
- icon: Settings,
- tone: "neutral",
- },
-];
-
-const recentWork = [
- {
- title: "Lithium monitoring in adults",
- detail: "Guidelines - RANZCP",
- time: "11:32 am",
- status: "Current",
- },
- {
- title: "ECT indications and safety",
- detail: "Guidelines - RANZCP",
- time: "Yesterday",
- status: "Saved",
- },
- {
- title: "Antipsychotic metabolic monitoring",
- detail: "Guidelines - RACGP",
- time: "2 days ago",
- status: "Reviewed",
- },
-] as const;
-
-const contextChips = ["WA", "Adults", "Conservative", "Current sources"] as const;
-
-const userProfilePlaceholder = {
- displayName: "Clinician profile",
- initials: "CL",
-} as const;
-
-const reviewQueue = [
- {
- title: "NICE NG222 - Depression in adults",
- priority: "High priority",
- due: "1d",
- },
- {
- title: "APA Practice Guideline - Schizophrenia",
- priority: "Medium priority",
- due: "2d",
- },
- {
- title: "CANMAT 2023 Update - Bipolar Disorder",
- priority: "Medium priority",
- due: "3d",
- },
-] as const;
-
-const savedProtocols = [
- {
- title: "Depression management",
- detail: "WA Health",
- },
- {
- title: "Psychosis first episode",
- detail: "RANZCP",
- },
- {
- title: "Lithium monitoring",
- detail: "RANZCP",
- },
- {
- title: "ECT quick reference",
- detail: "APA",
- },
-] as const;
-
-const importStatus = [
- {
- title: "RANZCP guidelines",
- detail: "Updated 2h ago",
- status: "Ready",
- },
- {
- title: "NICE updates",
- detail: "Updated 1d ago",
- status: "Ready",
- },
- {
- title: "APA guidelines",
- detail: "In progress",
- status: "Reviewing",
- },
- {
- title: "Cochrane reviews",
- detail: "Queued",
- status: "Queued",
- },
-] as const;
-
-const preferenceSummary = [
- ["Jurisdiction", "WA"],
- ["Population", "Adults"],
- ["Answer style", "Conservative"],
- ["Source policy", "Current sources"],
-] as const;
-
-const preferenceRows: Array<{
- title: string;
- body: string;
- status: string;
- icon: LucideIcon;
-}> = [
- {
- title: "Clinical defaults",
- body: "Adults 18+, WA region, current reviewed sources first",
- status: "Edit",
- icon: SlidersHorizontal,
- },
- {
- title: "Privacy and governance",
- body: "No patient identifiers on home, citation trail preserved",
- status: "On",
- icon: ShieldCheck,
- },
- {
- title: "Session security",
- body: "Current device protected with guarded local auth",
- status: "Protected",
- icon: KeyRound,
- },
- {
- title: "Clinical notifications",
- body: "Only source review, import status, and governance prompts",
- status: "Clinical only",
- icon: Bell,
- },
-] as const;
-
-const desktopNav: Array<{
- title: string;
- icon: LucideIcon;
- active?: boolean;
- badge?: string;
-}> = [
- { title: "Home", icon: Home, active: true },
- { title: "Ask", icon: MessageSquare },
- { title: "Sources", icon: BookOpen },
- { title: "Review", icon: ClipboardCheck, badge: "3" },
- { title: "Protocols", icon: BookOpenCheck },
- { title: "Import", icon: FileText },
- { title: "Settings", icon: Settings },
- { title: "Account", icon: UserRound },
-];
-
-const mobileNav = desktopNav.filter(({ title }) => ["Home", "Ask", "Sources", "Review", "Settings"].includes(title));
-
-const clinicalState = [
- ["Role", "Consultant psychiatrist"],
- ["Jurisdiction", "Western Australia"],
- ["Answer mode", "Source-backed guidance"],
- ["Source policy", "Current first"],
-] as const;
-
-function StatusPill({
- children,
- tone = "neutral",
-}: {
- children: string;
- tone?: "neutral" | "success" | "warning" | "primary";
-}) {
- return (
-
- {children}
-
- );
-}
-
-function DesktopSidebar() {
- return (
-
- );
-}
-
-function DesktopTopBar() {
- return (
-
- );
-}
-
-function MobileHeader() {
- return (
-
-
-
-
-
-
-
-
Clinical KB
-
Private workspace
-
-
-
- {userProfilePlaceholder.initials}
-
-
-
- );
-}
-
-function HeroHome() {
- return (
-
- Good afternoon,
-
- {userProfilePlaceholder.displayName}
-
-
-
-
- Role not set
-
-
-
-
- Jurisdiction not set
-
-
-
- Private
- Current first
- No PHI
-
-
- );
-}
-
-function ClinicalComposer() {
- return (
-
-
-
-
-
- Ask with clinical context
-
-
- Start source-backed answers with your preferred jurisdiction, population, and safety posture.
-
-
-
- Source-backed
-
-
-
-
-
-
- Ask about a guideline, medication, protocol, or safety question
-
-
-
-
-
-
-
- {contextChips.map((chip) => (
-
- {chip}
-
- ))}
-
-
-
- );
-}
-
-function PrimaryActions() {
- return (
-
-
- Quick actions
-
-
- {primaryActions.map(({ title, body, icon: Icon, tone }) => (
-
-
-
-
-
- {title}
-
-
- {body}
-
-
- ))}
-
-
- );
-}
-
-function ClinicalToolkit() {
- return (
-
-
-
-
-
-
Saved protocols
-
-
- Manage
-
-
-
-
- {savedProtocols.map(({ title, detail }) => (
-
- {title}
- {detail}
-
- ))}
-
-
-
-
-
-
-
-
Import status
-
-
Healthy
-
-
- {importStatus.slice(0, 3).map(({ title, detail, status }) => (
-
-
- {title}
- {detail}
-
- {status}
-
- ))}
-
-
-
- );
-}
-
-function RecentWork() {
- return (
-
-
-
Recent work
-
- View all
-
-
-
-
- {recentWork.map(({ title, detail, time, status }, index) => (
- 1 ? "hidden sm:grid" : "grid",
- )}
- >
-
-
-
-
-
- {title}
-
-
- {detail}
-
- {status}
-
-
-
- {time}
-
-
-
- ))}
-
-
- );
-}
-
-function SourceReviewQueue() {
- return (
-
-
-
-
Source review
- 3
-
-
- View queue
-
-
-
-
- {reviewQueue.map(({ title, priority, due }) => (
-
-
-
- {title}
-
- {priority} - {due}
-
-
-
-
- ))}
-
-
- );
-}
-
-function PrivacyGovernance() {
- return (
-
-
-
-
-
-
-
Privacy and governance
-
- Topic-based home content, visible citation trails, and no patient identifiers.
-
-
-
-
- );
-}
-
-function PreferenceRows() {
- return (
-
- {preferenceRows.map(({ title, body, status, icon: Icon }) => (
-
-
-
-
-
- {title}
- {body}
-
-
- {status}
-
-
-
- ))}
-
- );
-}
-
-function DesktopRightRail() {
- return (
-
-
-
-
-
-
-
-
Privacy and governance
-
- Topic-based home content, citation trails, and no patient identifiers.
-
-
-
-
-
-
-
-
-
Clinical context
-
-
- {clinicalState.map(([label, value]) => (
-
-
{label}
- {value}
-
- ))}
-
-
-
-
-
-
-
Answer preferences
-
-
- {preferenceSummary.map(([label, value]) => (
-
-
{label}
- {value}
-
- ))}
-
-
- Adjust defaults
-
-
-
-
- );
-}
-
-function MobileNav() {
- return (
-
-
- {mobileNav.map(({ title, icon: Icon, active, badge }) => (
-
-
-
- {badge ? (
-
- {badge}
-
- ) : null}
-
- {title}
-
- ))}
-
-
- );
-}
-
-export default function UserHomeProfilePage() {
- return (
-
-
-
-
-
- );
-}
diff --git a/src/lib/answer-ranking.ts b/src/lib/answer-ranking.ts
index 3dbccb656f..3ab45a1565 100644
--- a/src/lib/answer-ranking.ts
+++ b/src/lib/answer-ranking.ts
@@ -271,14 +271,29 @@ function escapeRegExp(value: string) {
function queryHighlightPatterns(query?: string) {
if (!query) return [];
- const tokens = uniqueQueryTokens(query).filter((token) => token.length >= 4 && !queryTermExclusions.has(token));
+ const lowValueHighlightTerms = new Set([
+ "dose",
+ "dosing",
+ "monitor",
+ "monitoring",
+ "test",
+ "tests",
+ "result",
+ "results",
+ "baseline",
+ "clinical",
+ "patient",
+ ]);
+ const tokens = uniqueQueryTokens(query).filter(
+ (token) => token.length >= 4 && !queryTermExclusions.has(token) && !lowValueHighlightTerms.has(token),
+ );
const patterns: RegExp[] = [];
const normalizedQuery = normalizeText(query);
const queryPhrase = tokens.length >= 2 ? tokens.join(" ") : "";
if (queryPhrase && normalizedQuery.includes(queryPhrase)) {
patterns.push(new RegExp(`\\b${escapeRegExp(queryPhrase).replace(/\\ /g, "\\s+")}\\b`, "gi"));
}
- for (const token of tokens.slice(0, 6).sort((a, b) => b.length - a.length)) {
+ for (const token of tokens.slice(0, 3).sort((a, b) => b.length - a.length)) {
patterns.push(new RegExp(`\\b${escapeRegExp(token)}\\w*\\b`, "gi"));
}
return patterns;
@@ -305,8 +320,11 @@ export function boldHighYieldClinicalText(text: string, query?: string) {
if (query === undefined) return text;
if (/[{}\[\]]/.test(text) && /"?(?:answer|heading|citation_chunk_ids|chunk_id)"?\s*:/i.test(text)) return text;
let output = text;
- for (const pattern of [...queryHighlightPatterns(query), ...fixedHighYieldPatterns]) {
- output = applyBoldPatternOutsideExisting(output, pattern, 8);
+ for (const pattern of queryHighlightPatterns(query)) {
+ output = applyBoldPatternOutsideExisting(output, pattern, 1);
+ }
+ for (const pattern of fixedHighYieldPatterns) {
+ output = applyBoldPatternOutsideExisting(output, pattern, 1);
}
return output;
}
diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts
index 6f8463fdf3..09df5b0847 100644
--- a/src/lib/api-rate-limit.ts
+++ b/src/lib/api-rate-limit.ts
@@ -33,6 +33,7 @@ type RateLimitRpcRow = {
type InMemoryRateLimitWindow = {
windowStartMs: number;
requestCount: number;
+ resetAtMs: number;
};
type GlobalWithRateLimitFallback = typeof globalThis & {
@@ -117,12 +118,20 @@ function consumeInMemoryApiRateLimit({
const now = Date.now();
const windowMs = windowSeconds * 1000;
const key = `${ownerId}:${bucket}`;
+
+ // Evict expired entries to prevent memory leak
+ for (const [k, v] of inMemoryApiRateLimits.entries()) {
+ if (now >= v.resetAtMs) {
+ inMemoryApiRateLimits.delete(k);
+ }
+ }
+
const current = inMemoryApiRateLimits.get(key);
const windowStartMs = current && now - current.windowStartMs < windowMs ? current.windowStartMs : now;
const requestCount = (current && current.windowStartMs === windowStartMs ? current.requestCount : 0) + 1;
const resetAtMs = windowStartMs + windowMs;
- inMemoryApiRateLimits.set(key, { windowStartMs, requestCount });
+ inMemoryApiRateLimits.set(key, { windowStartMs, requestCount, resetAtMs });
return {
limited: requestCount > limit,
diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts
index 5bbda9547f..57e048a9a5 100644
--- a/src/lib/app-modes.ts
+++ b/src/lib/app-modes.ts
@@ -191,10 +191,7 @@ export function appModeSearchConfig(modeId: AppModeId) {
return appModeDefinition(modeId).search;
}
-export function appModeHomeHref(
- modeId: AppModeId,
- options: { query?: string; focus?: boolean; run?: boolean } = {},
-) {
+export function appModeHomeHref(modeId: AppModeId, options: { query?: string; focus?: boolean; run?: boolean } = {}) {
const params = new URLSearchParams({ mode: modeId });
const query = options.query?.trim();
if (query) params.set("q", query);
diff --git a/src/lib/clinical-safety.ts b/src/lib/clinical-safety.ts
index 39ad2b9756..17a6da98ab 100644
--- a/src/lib/clinical-safety.ts
+++ b/src/lib/clinical-safety.ts
@@ -1,5 +1,6 @@
import { documentCitationHref, formatCitationLabel } from "@/lib/citations";
import { queryCoreTerms } from "@/lib/evidence-relevance";
+import { sanitizeAnswerText } from "@/lib/rag-answer-text";
import {
clinicalProseUsefulness,
sourceTextForCompactDisplay,
@@ -8,13 +9,7 @@ import {
import type { Citation, RagAnswer, SearchResult } from "@/lib/types";
export type SafetyFindingKind =
- | "contraindication"
- | "red_flag"
- | "escalation"
- | "dose_limit"
- | "monitoring"
- | "exclusion"
- | "caveat";
+ "contraindication" | "red_flag" | "escalation" | "dose_limit" | "monitoring" | "exclusion" | "caveat";
export type SafetyFinding = {
id: string;
@@ -132,7 +127,7 @@ export function extractSafetyFindings(answer: RagAnswer | null | undefined, limi
const findings: SafetyFinding[] = [];
for (const candidate of candidates) {
- const text = conciseSourceText(candidate.text);
+ const text = sanitizeAnswerText(conciseSourceText(candidate.text)) || conciseSourceText(candidate.text);
if (!text) continue;
if (answer.relevance) {
const sourceBacked = candidate.source?.relevance?.isSourceBacked;
diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts
index 150ecb6d06..05071f0e10 100644
--- a/src/lib/clinical-search.ts
+++ b/src/lib/clinical-search.ts
@@ -440,7 +440,10 @@ function queryClassFromSignals(args: {
return "document_lookup";
if (outsideCorpusMedicalPattern.test(args.normalizedQuery) && args.documentTitleTerms.length === 0)
return "unsupported_or_general";
- if (/\bflow\s*chart|flowchart\b/i.test(args.normalizedQuery) && /\b(?:next step|step after|after)\b/i.test(args.normalizedQuery))
+ if (
+ /\bflow\s*chart|flowchart\b/i.test(args.normalizedQuery) &&
+ /\b(?:next step|step after|after)\b/i.test(args.normalizedQuery)
+ )
return "document_lookup";
if (
/\b(?:dose|dosage|dosing|route|mg|mcg|microgram|\bim\b|\bpo\b|\bprn\b)\b/i.test(args.normalizedQuery) &&
@@ -454,7 +457,9 @@ function queryClassFromSignals(args: {
if (
args.documentTitleTerms.length > 0 &&
(!broadSummaryPattern.test(args.normalizedQuery) ||
- /\b(?:active community patients?|community patients in ed|patient safety plan|nocc)\b/i.test(args.normalizedQuery))
+ /\b(?:active community patients?|community patients in ed|patient safety plan|nocc)\b/i.test(
+ args.normalizedQuery,
+ ))
) {
return "document_lookup";
}
@@ -1252,7 +1257,9 @@ function rankingTieBreakScore(query: string, result: SearchResult, explanation:
const queryClass = analysis.queryClass;
const queryTokens = normalizedClinicalSearchTokens(query);
const titleText = normalizeQueryTokenForLookups(`${result.title} ${result.file_name}`);
- const sectionText = normalizeQueryTokenForLookups(`${result.section_heading ?? ""} ${(result.section_path ?? []).join(" ")}`);
+ const sectionText = normalizeQueryTokenForLookups(
+ `${result.section_heading ?? ""} ${(result.section_path ?? []).join(" ")}`,
+ );
const contentText = normalizeQueryTokenForLookups(result.content ?? "");
const tableText = normalizeQueryTokenForLookups(
(result.table_facts ?? [])
@@ -1275,7 +1282,9 @@ function rankingTieBreakScore(query: string, result: SearchResult, explanation:
const hasStructuredTable = hasStructuredThresholdEvidence(result) || hasNumericOrTableEvidence(result);
const hasDoseEvidence = hasDoseEvidenceSupport(result);
const hasDoseAmountEvidence = hasMedicationDoseAmountEvidence(result);
- const titleAliasHit = analysis.documentTitleTerms.some((term) => titleText.includes(normalizeQueryTokenForLookups(term)));
+ const titleAliasHit = analysis.documentTitleTerms.some((term) =>
+ titleText.includes(normalizeQueryTokenForLookups(term)),
+ );
let score = 0;
score += explanation.titleBoost * 0.32;
@@ -1294,8 +1303,12 @@ function rankingTieBreakScore(query: string, result: SearchResult, explanation:
if (queryClass === "medication_dose_risk" && hasDoseEvidence) score += 0.08;
if (queryClass === "medication_dose_risk" && hasDoseAmountEvidence) score += 0.14;
if ((queryClass === "table_threshold" || queryClass === "medication_dose_risk") && hasStructuredTable) score += 0.04;
- if (hasImageEvidenceNeed(query) && (result.images ?? []).some((image) => isClinicalImageEvidence(image))) score += 0.05;
- if (/\bflow\s*chart|flowchart|matrix|red\s*zone\b/i.test(query) && /\bflow\s*chart|flowchart|matrix|red\s*zone|risk\b/i.test(haystack))
+ if (hasImageEvidenceNeed(query) && (result.images ?? []).some((image) => isClinicalImageEvidence(image)))
+ score += 0.05;
+ if (
+ /\bflow\s*chart|flowchart|matrix|red\s*zone\b/i.test(query) &&
+ /\bflow\s*chart|flowchart|matrix|red\s*zone|risk\b/i.test(haystack)
+ )
score += 0.07;
if (/\bpatient safety plan\b/i.test(query) && /\bpatient safety plan\b/.test(titleText)) score += 0.18;
if (
@@ -1306,7 +1319,11 @@ function rankingTieBreakScore(query: string, result: SearchResult, explanation:
score += 0.45;
}
if (/\badmission\b/i.test(query) && /\bdischarge\b/i.test(query) && /\badmission\b/.test(titleText)) score += 0.08;
- if (/\badmission\b/i.test(query) && /\bdischarge\b/i.test(query) && /\badmission of community patient/.test(titleText))
+ if (
+ /\badmission\b/i.test(query) &&
+ /\bdischarge\b/i.test(query) &&
+ /\badmission of community patient/.test(titleText)
+ )
score += 0.22;
if (/\badmission\b/i.test(query) && /\bdischarge\b/i.test(query) && /\bdischarge\b/.test(titleText)) score += 0.04;
diff --git a/src/lib/clinical-vocabulary.ts b/src/lib/clinical-vocabulary.ts
index f4a5e5d323..aabecc0c98 100644
--- a/src/lib/clinical-vocabulary.ts
+++ b/src/lib/clinical-vocabulary.ts
@@ -1,13 +1,5 @@
export type ClinicalVocabularyType =
- | "medication"
- | "lab"
- | "service"
- | "form"
- | "risk"
- | "workflow"
- | "document_title"
- | "clinical_term"
- | "typo";
+ "medication" | "lab" | "service" | "form" | "risk" | "workflow" | "document_title" | "clinical_term" | "typo";
export type ClinicalVocabularyEntry = {
canonical: string;
diff --git a/src/lib/deep-memory.ts b/src/lib/deep-memory.ts
index 34f5966df4..ec814beed9 100644
--- a/src/lib/deep-memory.ts
+++ b/src/lib/deep-memory.ts
@@ -16,6 +16,7 @@ import {
} from "@/lib/model-index-extraction";
import { assertEmbeddingDim } from "@/lib/embedding-dimensions";
import { embedTexts } from "@/lib/openai";
+import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline";
import { sourceTextForDisplay, sourceTextForModel } from "@/lib/source-text-sanitizer";
import type {
ClinicalDocument,
@@ -80,6 +81,13 @@ function metadataRecord(metadata: unknown): Record
{
: {};
}
+function indexGenerationForChunks(chunks: Array<{ metadata?: Record | null }>) {
+ const generation = chunks
+ .map((chunk) => metadataRecord(chunk.metadata).index_generation_id)
+ .find((value): value is string => typeof value === "string" && value.trim().length > 0);
+ return generation ?? null;
+}
+
function compactText(value: string | null | undefined, limit = 420) {
const clean = sourceTextForModel(String(value ?? ""));
if (!clean) return "";
@@ -129,6 +137,7 @@ function sectionHeadingForChunk(chunk: MemoryChunk) {
export function buildDocumentSections(args: { document: MemoryDocument; chunks: MemoryChunk[] }): SectionInsertRow[] {
const sorted = [...args.chunks].sort((a, b) => a.chunk_index - b.chunk_index);
const groups: MemoryChunk[][] = [];
+ const indexGenerationId = indexGenerationForChunks(args.chunks);
for (const chunk of sorted) {
const previous = groups.at(-1);
@@ -164,6 +173,7 @@ export function buildDocumentSections(args: { document: MemoryDocument; chunks:
extraction_quality: extractionQualityForChunks(group),
metadata: {
rag_indexing_version: ragDeepMemoryVersion,
+ index_generation_id: indexGenerationId,
source_path: args.document.source_path ?? null,
},
};
@@ -283,6 +293,7 @@ function titleForCard(type: DocumentMemoryCardType, document: MemoryDocument, co
function createCard(args: {
document: MemoryDocument;
chunk?: MemoryChunk;
+ indexGenerationId?: string | null;
sectionIndex?: number;
type: DocumentMemoryCardType;
content: string;
@@ -306,6 +317,7 @@ function createCard(args: {
confidence: Math.max(0.35, Math.min(0.99, args.confidence)),
metadata: {
rag_indexing_version: ragDeepMemoryVersion,
+ index_generation_id: args.indexGenerationId ?? metadataRecord(args.chunk?.metadata).index_generation_id ?? null,
generated_by: "local-worker",
chunk_index: args.chunk?.chunk_index ?? null,
section_heading: args.chunk?.section_heading ?? null,
@@ -364,6 +376,7 @@ export function buildDocumentMemoryCards(args: {
}) {
const cards: BuiltMemoryCard[] = [];
const sections = args.sections ?? buildDocumentSections({ document: args.document, chunks: args.chunks });
+ const indexGenerationId = indexGenerationForChunks(args.chunks);
const chunkById = new Map(args.chunks.map((chunk) => [chunk.id, chunk]));
const imagesByPage = new Map();
for (const image of args.images ?? []) {
@@ -378,6 +391,7 @@ export function buildDocumentMemoryCards(args: {
type: "section_summary",
content: `${section.heading}: ${section.summary}`,
confidence: 0.68,
+ indexGenerationId,
metadata: { chunk_ids: section.chunk_ids, page_start: section.page_start, page_end: section.page_end },
}),
);
@@ -398,6 +412,7 @@ export function buildDocumentMemoryCards(args: {
type: "table_row",
content: row,
confidence: 0.9,
+ indexGenerationId,
sourceImageIds,
metadata: { extraction_source: "table_row" },
}),
@@ -415,6 +430,7 @@ export function buildDocumentMemoryCards(args: {
type: classification.type,
content: statement,
confidence: 0.55 + classification.score * 0.42,
+ indexGenerationId,
sourceImageIds,
metadata: { extraction_source: "chunk_statement" },
}),
@@ -835,7 +851,22 @@ export async function fetchMemoryCardsForQuery(args: {
const { data, error } = await queryBuilder;
if (error) return [];
- return ((data ?? []) as DocumentMemoryCard[])
+ const cards = (data ?? []) as DocumentMemoryCard[];
+ const documentIds = Array.from(new Set(cards.map((card) => card.document_id)));
+ const { data: documents } = documentIds.length
+ ? await args.supabase.from("documents").select("id,metadata").in("id", documentIds)
+ : { data: [] };
+ const committedGenerationByDocument = new Map(
+ (documents ?? []).map((document) => [document.id, committedIndexGeneration(document.metadata)] as const),
+ );
+
+ return cards
+ .filter((card) =>
+ isCommittedGenerationMetadata({
+ rowMetadata: card.metadata,
+ committedGeneration: committedGenerationByDocument.get(card.document_id),
+ }),
+ )
.map((card) => ({ ...card, confidence: Number(card.confidence ?? 0.5) }))
.sort((a, b) => scoreMemoryCardForQuery(args.query, b) - scoreMemoryCardForQuery(args.query, a))
.slice(0, args.matchCount ?? 32);
diff --git a/src/lib/document-enrichment.ts b/src/lib/document-enrichment.ts
index c6019a03f2..0d6249d001 100644
--- a/src/lib/document-enrichment.ts
+++ b/src/lib/document-enrichment.ts
@@ -402,10 +402,21 @@ export function inferLabels(document: Pick | null } | null) {
+ const generation = chunk?.metadata?.index_generation_id;
+ return typeof generation === "string" && generation.trim() ? generation.trim() : null;
+}
+
const sentenceBoundary = /(?<=[.!?])\s+|\n+/;
const thresholdPattern =
/\b(?:threshold|cut[\s-]?off|level|range|score|scale|criteria|criterion|maximum|minimum|baseline|anc|fbc|wbc|neutrophil|withhold|cease|stop|urgent|review|<|>|<=|>=|\d+(?:\.\d+)?\s*(?:mg|mcg|mmol|x\s*10\^?9\/l|%))\b/i;
@@ -214,12 +219,15 @@ function visualProfileForImage(image: IndexUnitVisualImage) {
function visualFamilyKey(image: IndexUnitVisualImage) {
const metadata = image.metadata ?? {};
- const family = metadata.visual_family_id ?? metadata.visual_duplicate_group ?? metadata.perceptual_hash ?? metadata.image_hash;
+ const family =
+ metadata.visual_family_id ?? metadata.visual_duplicate_group ?? metadata.perceptual_hash ?? metadata.image_hash;
return typeof family === "string" && family.trim() ? family.trim() : image.id;
}
function visualRepresentativeScore(image: IndexUnitVisualImage) {
- const profileConfidence = Number(image.structuredVisualProfile?.confidence ?? image.metadata?.structured_extraction_confidence ?? 0.55);
+ const profileConfidence = Number(
+ image.structuredVisualProfile?.confidence ?? image.metadata?.structured_extraction_confidence ?? 0.55,
+ );
const priority = Number(image.candidatePriorityScore ?? image.metadata?.candidate_priority_score ?? 0.55);
const quality = Number(image.imageQualityScore ?? image.metadata?.image_quality_score ?? 0.55);
const density = Number(image.ocrTextDensity ?? image.metadata?.ocr_text_density ?? 0);
@@ -235,17 +243,25 @@ function representativeVisualImages(images: IndexUnitVisualImage[]) {
bestByFamily.set(familyKey, image);
}
}
- return [...bestByFamily.values()].sort((left, right) => visualRepresentativeScore(right) - visualRepresentativeScore(left));
+ return [...bestByFamily.values()].sort(
+ (left, right) => visualRepresentativeScore(right) - visualRepresentativeScore(left),
+ );
}
function sourceChunkForImage(image: IndexUnitVisualImage, chunks: IndexUnitChunk[]) {
- const linked = chunks.find((chunk) => Array.isArray((chunk as { image_ids?: string[] }).image_ids) && (chunk as { image_ids?: string[] }).image_ids?.includes(image.id));
+ const linked = chunks.find(
+ (chunk) =>
+ Array.isArray((chunk as { image_ids?: string[] }).image_ids) &&
+ (chunk as { image_ids?: string[] }).image_ids?.includes(image.id),
+ );
if (linked) return linked;
return chunks.find((chunk) => image.pageNumber !== null && chunk.page_number === image.pageNumber) ?? null;
}
function visualTitle(image: IndexUnitVisualImage) {
- return image.tableTitle || image.tableLabel || image.caption || `Visual evidence page ${image.pageNumber ?? "unknown"}`;
+ return (
+ image.tableTitle || image.tableLabel || image.caption || `Visual evidence page ${image.pageNumber ?? "unknown"}`
+ );
}
function visualSearchableText(image: IndexUnitVisualImage, profile: StructuredVisualProfile, title: string) {
@@ -266,9 +282,14 @@ function visualSearchableText(image: IndexUnitVisualImage, profile: StructuredVi
.join(" ");
}
-function fallbackVisualUnitType(image: IndexUnitVisualImage, profile: StructuredVisualProfile, text: string): DocumentIndexUnitType {
+function fallbackVisualUnitType(
+ image: IndexUnitVisualImage,
+ profile: StructuredVisualProfile,
+ text: string,
+): DocumentIndexUnitType {
if (/\b(?:flow\s*chart|flowchart|algorithm|decision|yes|no|next step|pathway)\b/i.test(text)) return "flowchart_step";
- if (/\b(?:risk matrix|red zone|likelihood|consequence|high risk|visual alert)\b/i.test(text)) return "risk_matrix_cell";
+ if (/\b(?:risk matrix|red zone|likelihood|consequence|high risk|visual alert)\b/i.test(text))
+ return "risk_matrix_cell";
if (
/\b(?:medication|medicine|dose|dosage|mg|mcg|microgram|route|oral|intramuscular|\bim\b|\bpo\b|frequency)\b/i.test(
text,
@@ -330,10 +351,12 @@ function visualUnit(args: {
page_number: args.image.pageNumber,
source_region: sourceRegion,
visual_family_id: metadata.visual_family_id ?? visualFamilyKey(args.image),
- visual_duplicate_group: metadata.visual_duplicate_group ?? metadata.perceptual_hash ?? metadata.image_hash ?? null,
+ visual_duplicate_group:
+ metadata.visual_duplicate_group ?? metadata.perceptual_hash ?? metadata.image_hash ?? null,
structured_extraction_confidence: args.profile.confidence,
image_quality_score: args.image.imageQualityScore ?? args.image.metadata?.image_quality_score ?? null,
- candidate_priority_score: args.image.candidatePriorityScore ?? args.image.metadata?.candidate_priority_score ?? null,
+ candidate_priority_score:
+ args.image.candidatePriorityScore ?? args.image.metadata?.candidate_priority_score ?? null,
...args.metadata,
},
});
@@ -435,7 +458,9 @@ export function buildVisualDocumentIndexUnitInputs(args: {
chunks: args.chunks,
unit_type: "table_threshold",
title: threshold.label,
- content: [title, threshold.label, threshold.value, threshold.action, threshold.source_text].filter(Boolean).join(" | "),
+ content: [title, threshold.label, threshold.value, threshold.action, threshold.source_text]
+ .filter(Boolean)
+ .join(" | "),
profile,
quality_score: threshold.confidence,
metadata: { visual_item_type: "threshold", threshold },
@@ -569,6 +594,7 @@ function buildUnit(args: {
metadata: {
document_index_unit_version: documentIndexUnitVersion,
document_intelligence_version: documentIntelligenceVersion,
+ index_generation_id: indexGenerationFromChunk(args.sourceChunk),
chunk_index: args.sourceChunk?.chunk_index ?? null,
section_heading: args.sourceChunk?.section_heading ?? null,
...args.metadata,
diff --git a/src/lib/document-organization.ts b/src/lib/document-organization.ts
index 3dc8d4c5be..339033bad3 100644
--- a/src/lib/document-organization.ts
+++ b/src/lib/document-organization.ts
@@ -36,38 +36,152 @@ type SecondaryFacet = {
const organizationProfileVersion = "document-organization-v1";
const siteDefinitions: SiteDefinition[] = [
+ // ── Individual hospitals ──────────────────────────────────────────────────
+ {
+ canonical: "Royal Perth Hospital",
+ rawTags: ["rph"],
+ kind: "hospital",
+ evidence: [/\broyal perth hospital\b/i, /\brph\b/i],
+ },
+ {
+ canonical: "Sir Charles Gairdner Hospital",
+ rawTags: ["scgh"],
+ kind: "hospital",
+ evidence: [/\bsir charles gairdner\b/i, /\bscgh\b/i],
+ },
+ {
+ canonical: "Perth Children's Hospital",
+ rawTags: ["pch"],
+ kind: "hospital",
+ evidence: [/\bperth children['']?s hospital\b/i, /\bpch\b/i],
+ },
+ {
+ canonical: "King Edward Memorial Hospital",
+ rawTags: ["kemh"],
+ kind: "hospital",
+ evidence: [/\bking edward memorial\b/i, /\bkemh\b/i],
+ },
{
canonical: "Fiona Stanley Hospital",
- rawTags: ["fsh", "fh"],
+ rawTags: ["fsh"],
+ kind: "hospital",
+ evidence: [/\bfiona stanley\b/i, /\bfsh\b/i],
+ },
+ {
+ canonical: "Fremantle Hospital",
+ rawTags: ["fh", "freo"],
+ kind: "hospital",
+ evidence: [/\bfremantle hospital\b/i, /\bfremantle health\b/i],
+ },
+ {
+ canonical: "Armadale Kalamunda Group",
+ rawTags: ["akg", "armadale"],
+ kind: "hospital",
+ evidence: [/\barmadale kalamunda\b/i, /\barmadale hospital\b/i, /\bakg\b/i],
+ },
+ {
+ canonical: "Joondalup Health Campus",
+ rawTags: ["jhc", "joondalup"],
kind: "hospital",
- evidence: [/\bfiona stanley\b/i, /\bfsh\b/i, /\bfremantle hospital\b/i],
+ evidence: [/\bjoondalup health campus\b/i, /\bjoondalup hospital\b/i, /\bjhc\b/i],
},
+ {
+ canonical: "Osborne Park Hospital",
+ rawTags: ["oph", "osborne park"],
+ kind: "hospital",
+ evidence: [/\bosborne park hospital\b/i, /\boph\b/i],
+ },
+ {
+ canonical: "Swan / Midland Hospitals",
+ rawTags: ["swan", "midland", "smh"],
+ kind: "hospital",
+ evidence: [/\bswan hospital\b/i, /\bmidland hospital\b/i, /\bst john of god midland\b/i],
+ },
+ {
+ canonical: "Bentley Hospital",
+ rawTags: ["bentley"],
+ kind: "hospital",
+ evidence: [/\bbentley hospital\b/i],
+ },
+ {
+ canonical: "Rockingham General Hospital",
+ rawTags: ["rgh", "rockingham"],
+ kind: "hospital",
+ evidence: [/\brockingham general hospital\b/i, /\brgh\b/i],
+ },
+ {
+ canonical: "Peel Health Campus",
+ rawTags: ["phc", "peel"],
+ kind: "hospital",
+ evidence: [/\bpeel health campus\b/i, /\bphc\b/i],
+ },
+ {
+ canonical: "Graylands / Neuropsychiatric",
+ rawTags: ["graylands", "npscu"],
+ kind: "hospital",
+ evidence: [/\bgraylands\b/i, /\bneuropsychiatric\b/i, /\bnpscu\b/i],
+ },
+
+ // ── Health services / networks ────────────────────────────────────────────
{
canonical: "East Metropolitan Health Service",
- rawTags: ["emhs policy", "emhs"],
+ rawTags: ["emhs", "emhs policy"],
kind: "health_service",
evidence: [/\beast metropolitan health service\b/i, /\bemhs\b/i],
},
{
canonical: "South Metropolitan Health Service",
- rawTags: ["smhs policy", "smhs"],
+ rawTags: ["smhs", "smhs policy"],
kind: "health_service",
evidence: [/\bsouth metropolitan health service\b/i, /\bsmhs\b/i],
},
+ {
+ canonical: "North Metropolitan Health Service",
+ rawTags: ["nmhs", "nmhs policy"],
+ kind: "health_service",
+ evidence: [/\bnorth metropolitan health service\b/i, /\bnmhs\b/i],
+ },
+ {
+ canonical: "Child and Adolescent Health Service",
+ rawTags: ["cahs"],
+ kind: "health_service",
+ evidence: [/\bchild and adolescent health service\b/i, /\bcahs\b/i],
+ },
+ {
+ canonical: "WA Country Health Service",
+ rawTags: ["wachs"],
+ kind: "health_service",
+ evidence: [/\bwa country health service\b/i, /\bwachs\b/i],
+ },
{
canonical: "Rockingham Peel Group",
rawTags: ["rkpg", "rockingham peel group"],
kind: "health_service",
evidence: [/\brockingham peel\b/i, /\brkpg\b/i],
},
+
+ // ── Specialty programs / services ─────────────────────────────────────────
{
canonical: "Child and Adolescent Mental Health Service",
rawTags: ["camhs"],
kind: "program",
evidence: [/\bchild and adolescent mental health\b/i, /\bcamhs\b/i],
},
+ {
+ canonical: "Mental Health Commission",
+ rawTags: ["mhc"],
+ kind: "program",
+ evidence: [/\bmental health commission\b/i, /\bmhc\b/i],
+ },
+ {
+ canonical: "WA Health",
+ rawTags: ["wah", "wa health", "doh"],
+ kind: "health_service",
+ evidence: [/\bwa health\b/i, /\bdepartment of health\b/i, /\bdoh\b/i],
+ },
];
+
const secondaryTagMap = new Map([
["adult", { label: "adult", label_type: "population" }],
["child", { label: "child", label_type: "population" }],
@@ -87,12 +201,35 @@ const documentTypePatterns: Array<{
patterns: RegExp[];
}> = [
{ label: "policy", confidence: 0.9, patterns: [/\bpolicy\b/i] },
- { label: "procedure", confidence: 0.88, patterns: [/\bprocedure\b/i, /\bprocedural\b/i] },
+ { label: "procedure", confidence: 0.88, patterns: [/\bprocedure\b/i, /\bprocedural\b/i, /\bsop\b/i] },
{ label: "guideline", confidence: 0.84, patterns: [/\bguideline\b/i, /\bguidance\b/i] },
{ label: "protocol", confidence: 0.84, patterns: [/\bprotocol\b/i] },
{ label: "form", confidence: 0.82, patterns: [/\bform\b/i, /\brequest\b/i, /\breferral\b/i] },
{ label: "checklist", confidence: 0.82, patterns: [/\bchecklist\b/i] },
- { label: "pathway", confidence: 0.82, patterns: [/\bpathway\b/i, /\bflowchart\b/i] },
+ { label: "pathway", confidence: 0.82, patterns: [/\bpathway\b/i] },
+ { label: "algorithm", confidence: 0.84, patterns: [/\balgorithm\b/i, /\bflowchart\b/i, /\bdecision tree\b/i] },
+ {
+ label: "factsheet",
+ confidence: 0.82,
+ patterns: [
+ /\bfactsheet\b/i,
+ /\bfact\s*sheet\b/i,
+ /\bpatient information\b/i,
+ /\bpatient info\b/i,
+ /\bconsumer info\b/i,
+ ],
+ },
+ { label: "manual", confidence: 0.82, patterns: [/\bmanual\b/i, /\bhandbook\b/i, /\borientation\b/i] },
+ {
+ label: "assessment_tool",
+ confidence: 0.82,
+ patterns: [/\btool\b/i, /\bscale\b/i, /\bscore\b/i, /\bassessment\b/i],
+ },
+ {
+ label: "prescribing_aid",
+ confidence: 0.82,
+ patterns: [/\bprescrib\b/i, /\baid\b/i, /\bcalculator\b/i, /\bdosing\b/i, /\bnomogram\b/i],
+ },
{ label: "reference", confidence: 0.72, patterns: [/\breference\b/i, /\binformation sheet\b/i, /\bplacecard\b/i] },
];
@@ -223,18 +360,43 @@ function classifySite(input: OrganizationDocumentInput, rawTags: string[]) {
}
function classifyDocumentType(input: OrganizationDocumentInput): DocumentOrganizationProfile["document_type"] {
- const text = [
- input.title,
- input.file_name,
- input.source_path ?? "",
- input.summaryText ?? "",
- input.contentText ?? "",
- metadataString(input.metadata, "source_type"),
- metadataString(input.metadata, "category"),
- ].join(" ");
- const matched = documentTypePatterns.find((candidate) => candidate.patterns.some((pattern) => pattern.test(text)));
- if (!matched) return { label: "unknown", confidence: 0.2, evidence_sources: [] };
- return { label: matched.label, confidence: matched.confidence, evidence_sources: [`pattern:${matched.label}`] };
+ const titleText = `${input.title} ${input.file_name}`;
+ const matchedTitle = documentTypePatterns.find((candidate) =>
+ candidate.patterns.some((pattern) => pattern.test(titleText)),
+ );
+ if (matchedTitle) {
+ return {
+ label: matchedTitle.label,
+ confidence: matchedTitle.confidence,
+ evidence_sources: [`title_pattern:${matchedTitle.label}`],
+ };
+ }
+
+ const metaText = `${metadataString(input.metadata, "source_type")} ${metadataString(input.metadata, "category")}`;
+ const matchedMeta = documentTypePatterns.find((candidate) =>
+ candidate.patterns.some((pattern) => pattern.test(metaText)),
+ );
+ if (matchedMeta) {
+ return {
+ label: matchedMeta.label,
+ confidence: Math.max(0.5, matchedMeta.confidence - 0.05),
+ evidence_sources: [`metadata_pattern:${matchedMeta.label}`],
+ };
+ }
+
+ const fullText = `${input.source_path ?? ""} ${input.summaryText ?? ""} ${input.contentText ?? ""}`;
+ const matchedContent = documentTypePatterns.find((candidate) =>
+ candidate.patterns.some((pattern) => pattern.test(fullText)),
+ );
+ if (matchedContent) {
+ return {
+ label: matchedContent.label,
+ confidence: Math.max(0.5, matchedContent.confidence - 0.15),
+ evidence_sources: [`content_pattern:${matchedContent.label}`],
+ };
+ }
+
+ return { label: "unknown", confidence: 0.2, evidence_sources: [] };
}
function emptySecondaryFacets(): DocumentOrganizationProfile["secondary_facets"] {
@@ -306,7 +468,11 @@ export function classifyDocumentOrganization(input: OrganizationDocumentInput) {
const raw_bracket_tags = extractDocumentBracketTags(input.title, input.file_name, input.source_path);
const site = classifySite(input, raw_bracket_tags);
const document_type = classifyDocumentType(input);
- const review_status = site.label || site.candidates.length === 0 ? "confident" : "needs_review";
+
+ const type_confident = document_type.label !== "unknown" && document_type.confidence >= 0.7;
+ const site_confident = site.label || site.candidates.length === 0;
+ const review_status = type_confident && site_confident ? "confident" : "needs_review";
+
const profile: DocumentOrganizationProfile = {
canonical_display_title: canonicalDocumentDisplayTitle(input),
raw_bracket_tags,
diff --git a/src/lib/image-filtering.ts b/src/lib/image-filtering.ts
index 08302f790f..8c39399cff 100644
--- a/src/lib/image-filtering.ts
+++ b/src/lib/image-filtering.ts
@@ -3,11 +3,7 @@ import type { ExtractedImage, ImageEvidenceCategory } from "@/lib/types";
export const clinicalImagePolicyVersion = "clinical-image-use-v1" as const;
export type ClinicalImageUseClass =
- | "clinical_evidence"
- | "administrative"
- | "reference"
- | "decorative_or_empty"
- | "ambiguous";
+ "clinical_evidence" | "administrative" | "reference" | "decorative_or_empty" | "ambiguous";
export type CheapImageFilterInput = {
bytesLength: number;
diff --git a/src/lib/index-quality.ts b/src/lib/index-quality.ts
index b881f5d063..05710beff0 100644
--- a/src/lib/index-quality.ts
+++ b/src/lib/index-quality.ts
@@ -70,15 +70,17 @@ export function assessDocumentIndexQuality(args: {
.filter((score) => Number.isFinite(score));
const visualUnitCoverage = searchableVisuals
? args.insertedImages.filter((image) => {
- const profile = image.structuredVisualProfile ?? (image.metadata?.structured_visual_profile as typeof image.structuredVisualProfile);
+ const profile =
+ image.structuredVisualProfile ??
+ (image.metadata?.structured_visual_profile as typeof image.structuredVisualProfile);
return Boolean(
profile &&
- ((profile.thresholds?.length ?? 0) +
- (profile.flowchart_nodes?.length ?? 0) +
- (profile.risk_matrix_cells?.length ?? 0) +
- (profile.chart_findings?.length ?? 0) >
- 0 ||
- image.tableRows?.length),
+ ((profile.thresholds?.length ?? 0) +
+ (profile.flowchart_nodes?.length ?? 0) +
+ (profile.risk_matrix_cells?.length ?? 0) +
+ (profile.chart_findings?.length ?? 0) >
+ 0 ||
+ image.tableRows?.length),
);
}).length / searchableVisuals
: null;
@@ -159,7 +161,9 @@ export function assessDocumentIndexQuality(args: {
searchable_image_coverage: searchableImageCoverage === null ? null : Number(searchableImageCoverage.toFixed(3)),
visual_unit_coverage: visualUnitCoverage === null ? null : Number(visualUnitCoverage.toFixed(3)),
average_image_quality_score: visualQualityScores.length ? Number(average(visualQualityScores).toFixed(3)) : null,
- average_crop_completeness: cropCompletenessScores.length ? Number(average(cropCompletenessScores).toFixed(3)) : null,
+ average_crop_completeness: cropCompletenessScores.length
+ ? Number(average(cropCompletenessScores).toFixed(3))
+ : null,
average_structured_visual_confidence: structuredConfidenceScores.length
? Number(average(structuredConfidenceScores).toFixed(3))
: null,
diff --git a/src/lib/openai.ts b/src/lib/openai.ts
index 9defda9bd0..f91c3d040f 100644
--- a/src/lib/openai.ts
+++ b/src/lib/openai.ts
@@ -10,12 +10,7 @@ import {
import type { ImageEvidenceCategory, OpenAITokenUsage } from "@/lib/types";
type OpenAIOperation =
- | "embedding"
- | "answer"
- | "summary"
- | "vision_caption"
- | "vision_classification"
- | "text_generation";
+ "embedding" | "answer" | "summary" | "vision_caption" | "vision_classification" | "text_generation";
type OpenAIReasoningEffort = "none" | "minimal" | "low" | "medium" | "high";
type OpenAITextVerbosity = "low" | "medium" | "high";
diff --git a/src/lib/public-rate-limit.ts b/src/lib/public-rate-limit.ts
index 0769430810..0d0d17902e 100644
--- a/src/lib/public-rate-limit.ts
+++ b/src/lib/public-rate-limit.ts
@@ -40,6 +40,13 @@ function consumePublicRateLimit(
): PublicRateLimitResult {
const limit = options.limit;
const windowMs = options.windowMs;
+ // Evict expired entries to prevent memory leak
+ for (const [k, v] of buckets.entries()) {
+ if (now >= v.resetAt) {
+ buckets.delete(k);
+ }
+ }
+
const key = publicRateLimitKey(headers);
const existing = buckets.get(key);
const bucket =
diff --git a/src/lib/rag-answer-text.ts b/src/lib/rag-answer-text.ts
index f8ecb3b18f..2791e8665f 100644
--- a/src/lib/rag-answer-text.ts
+++ b/src/lib/rag-answer-text.ts
@@ -14,6 +14,34 @@ const answerSectionArtifactPattern =
// must NOT cause the rest of the answer/section to be sliced away.
const leakedJsonKeyPattern =
/"(answer|heading|body|grounded|confidence|citations?|answerSections?|citation_chunk_ids|conflictsOrGaps|quoteCards?|source_chunk_ids|chunk_id)"\s*:/i;
+const productCatalogueFragmentPattern =
+ /\b[A-Z][A-Za-z ]+\s+\d+\s*mg\b[^.?!]*?\b(?:tablet|capsule|solution|modified release|enteric-coated)\b[^.?!]*?[®™]\s*/gi;
+const brandOrFormularyFragmentPattern =
+ /\b(?:Lithicarb|Quilonum\s+SR|Campral)[®™]?|\b(?:imprest|formulary)\s+(?:location|one)\b.*?(?=\b(?:therapy|treatment|start|commence|begin|check|monitor|baseline|dose|dosing)\b|[.?!]|$)/gi;
+const imprestLocationPattern =
+ /\bimprest\s+location\s*:\s*.*?(?=\b(?:therapy|treatment|start|commence|begin|check|monitor|baseline|dose|dosing)\b|$)/gi;
+const allCapsSourceHeadingPattern = /\b(?=[A-Z0-9/&,+() -]*\s[A-Z0-9])[A-Z][A-Z0-9/&,+() -]{8,}\b/g;
+const sourceFormCodePattern = /\b[A-Z]{2,8}\d{3,}(?:\/\d+)?\b/g;
+const bracketedCitationMarkerPattern =
+ /\s*(?:\[\s*\d+(?:\s*[-,]\s*\d+)*\s*\]|\(\s*\d+(?:\s*[-,]\s*\d+)*\s*\))(?=\.?(?:\s|$))/g;
+const trailingCitationDigitPattern = /(?<=[a-z)])\d+(?=\.?(?:\s|$))/g;
+const clinicalAbbreviationCitationDigitPattern =
+ /\b(ANC|FBC|WBC|ECG|EEG|LFTs?|UEC|U&E|QTc|BMI|BP|HR|RR|CRP|ESR|TSH|HbA1c)\d+(?=\.?(?:\s|$))/gi;
+const orphanSourceHeadingPattern =
+ /^(?:lithium\s+)?(?:monitoring|baseline tests?|dose|dosage|dosage adjustments?|therapy|source|table|section)$/i;
+const sourceInventoryWordingPattern =
+ /\b(?:the\s+(?:strongest\s+)?retrieved\s+(?:source|sources|passages|excerpts)\s+(?:support|supports|show|shows|indicate|indicates)|retrieved\s+(?:source|sources|passages|excerpts)|indexed\s+source\s+passages\s+matched|no\s+concise\s+source\s+sentence|source-backed|based\s+on\s+(?:the\s+)?(?:provided\s+)?(?:sources|excerpts|passages|retrieved\s+sources)|dose evidence|monitoring evidence|table evidence|direct source-backed answer)\b/i;
+const clippedClinicalFragmentPattern =
+ /\b(?:stabili[sz]e\s+the\s+do|the\s+do\b|liver\s+functi\b|respiratio\b|if\s+a\s+60%\s+decrease\s+in\s+b\b)\b/i;
+const genericMedicationCasePatterns: Array<[RegExp, string]> = [
+ [/\bLithium Carbonate\b/g, "lithium carbonate"],
+ [/\bClozapine\b/g, "clozapine"],
+ [/\bAcamprosate\b/g, "acamprosate"],
+ [/\bSertraline\b/g, "sertraline"],
+ [/\bNaltrexone\b/g, "naltrexone"],
+ [/\bDisulfiram\b/g, "disulfiram"],
+ [/\bBaclofen\b/g, "baclofen"],
+];
export function normalizeSectionText(value: string) {
return value.trim().replace(/\s+/g, " ");
@@ -117,8 +145,96 @@ export function sanitizeStructuredText(
return usefulness.text || finalText;
}
+function normalizeGenericMedicationCase(value: string) {
+ let normalized = value;
+ for (const [pattern, replacement] of genericMedicationCasePatterns) {
+ normalized = normalized.replace(pattern, replacement);
+ }
+ return normalized;
+}
+
+function answerSentenceFragments(value: string) {
+ return value.match(/(?:\d+\.\d+|[^.!?])+[.!?]?/g) ?? [value];
+}
+
+function removeOrphanAnswerHeadings(value: string) {
+ const fragments = answerSentenceFragments(value);
+ return fragments
+ .map((fragment) => normalizeSectionText(fragment))
+ .filter((fragment) => {
+ const normalized = fragment.replace(/[.!?]+$/, "").trim();
+ if (!normalized) return false;
+ if (orphanSourceHeadingPattern.test(normalized)) return false;
+ return true;
+ })
+ .join(" ");
+}
+
+function removeBadAnswerFragments(value: string) {
+ const fragments = answerSentenceFragments(value);
+ return fragments
+ .map((fragment) => normalizeSectionText(fragment))
+ .filter((fragment) => {
+ const normalized = fragment.replace(/[.!?]+$/, "").trim();
+ if (!normalized) return false;
+ if (sourceInventoryWordingPattern.test(normalized)) return false;
+ if (clippedClinicalFragmentPattern.test(normalized)) return false;
+ brandOrFormularyFragmentPattern.lastIndex = 0;
+ if (brandOrFormularyFragmentPattern.test(normalized)) return false;
+ if (/\btable\s+\d+\b/i.test(normalized) && normalized.length > 180) return false;
+ return true;
+ })
+ .join(" ");
+}
+
+export function polishClinicalAnswerProse(value: string) {
+ const cleaned = normalizeSectionText(value)
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
+ .replace(productCatalogueFragmentPattern, " ")
+ .replace(brandOrFormularyFragmentPattern, " ")
+ .replace(imprestLocationPattern, " ")
+ .replace(allCapsSourceHeadingPattern, " ")
+ .replace(sourceFormCodePattern, " ")
+ .replace(/\b(?:dose evidence|monitoring evidence|table evidence|source point)\s*:\s*/gi, " ")
+ .replace(/\s+to\s+stabili[sz]e\s+the\s+do\b\.?/gi, ".")
+ .replace(/\b(?:liver functi|respiratio)\b[^.?!]*[.?!]?/gi, " ")
+ .replace(bracketedCitationMarkerPattern, "")
+ .replace(clinicalAbbreviationCitationDigitPattern, "$1")
+ .replace(/(?<=[a-z)])\s*\.\s*\d+(?=\.?(?:\s|$))/g, ".")
+ .replace(trailingCitationDigitPattern, "")
+ .replace(/\s+([,.;:])/g, "$1")
+ .replace(/(?:\.\s*){2,}/g, ". ")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ return normalizeGenericMedicationCase(removeOrphanAnswerHeadings(removeBadAnswerFragments(cleaned)));
+}
+
export function sanitizeAnswerText(value: string) {
- return sanitizeStructuredText(value, { minLength: 8, minTokens: 2, keepLeading: true });
+ const cleaned = sanitizeStructuredText(value, { minLength: 8, minTokens: 2, keepLeading: true });
+ return cleaned ? polishClinicalAnswerProse(cleaned) : "";
+}
+
+export function hasClinicalAnswerQualityIssue(value: string) {
+ const normalized = normalizeSectionText(value);
+ if (!normalized) return true;
+ productCatalogueFragmentPattern.lastIndex = 0;
+ brandOrFormularyFragmentPattern.lastIndex = 0;
+ allCapsSourceHeadingPattern.lastIndex = 0;
+ sourceFormCodePattern.lastIndex = 0;
+ bracketedCitationMarkerPattern.lastIndex = 0;
+ clinicalAbbreviationCitationDigitPattern.lastIndex = 0;
+ return (
+ sourceInventoryWordingPattern.test(normalized) ||
+ clippedClinicalFragmentPattern.test(normalized) ||
+ productCatalogueFragmentPattern.test(normalized) ||
+ brandOrFormularyFragmentPattern.test(normalized) ||
+ allCapsSourceHeadingPattern.test(normalized) ||
+ sourceFormCodePattern.test(normalized) ||
+ bracketedCitationMarkerPattern.test(normalized) ||
+ clinicalAbbreviationCitationDigitPattern.test(normalized) ||
+ /(?<=[a-z)])\d+(?=\.?(?:\s|$))/.test(normalized)
+ );
}
export function isUsableAnswerSectionText(value: string, options: { minTokens?: number; minLength?: number } = {}) {
diff --git a/src/lib/rag.ts b/src/lib/rag.ts
index cdd96944e9..68a12b7f10 100644
--- a/src/lib/rag.ts
+++ b/src/lib/rag.ts
@@ -29,6 +29,7 @@ import {
sourceTextForModel,
} from "@/lib/source-text-sanitizer";
import {
+ hasClinicalAnswerQualityIssue,
isUsableAnswerSectionText,
looksLikeJsonArtifact,
normalizeSectionText,
@@ -44,6 +45,7 @@ import {
import { buildSmartRagApiPlan } from "@/lib/smart-rag-api";
import { clinicalModePrompt, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode";
import { annotateSearchResults, buildEvidenceRelevance } from "@/lib/evidence-relevance";
+import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline";
import { z } from "zod";
import { createHash } from "node:crypto";
import {
@@ -484,7 +486,9 @@ function secondStageScore(result: SearchResult, queryClass: RagQueryClass | unde
const doseAmountText = `${result.section_heading ?? ""} ${result.content} ${(result.images ?? [])
.map((image) => `${image.caption ?? ""} ${image.tableTextSnippet ?? ""} ${image.tableTitle ?? ""}`)
.join(" ")} ${(result.table_facts ?? [])
- .map((fact) => `${fact.table_title ?? ""} ${fact.row_label ?? ""} ${fact.threshold_value ?? ""} ${fact.action ?? ""}`)
+ .map(
+ (fact) => `${fact.table_title ?? ""} ${fact.row_label ?? ""} ${fact.threshold_value ?? ""} ${fact.action ?? ""}`,
+ )
.join(" ")}`;
const hasDoseAmount = /\b\d+(?:\.\d+)?\s?(?:mg|mcg|microgram|micrograms)\b/i.test(doseAmountText);
score += Math.max(0, 0.09 - index * 0.004);
@@ -759,6 +763,18 @@ function removeIncompleteTrailingSentence(value: string) {
return complete.length >= 32 ? complete : text;
}
+function sanitizeAnswerSectionHeadingText(heading: string, body: string) {
+ const structuredHeading = sanitizeStructuredText(heading, { minLength: 1, minTokens: 1 });
+ const polishedHeading = structuredHeading ? sanitizeAnswerText(structuredHeading) || structuredHeading : "";
+ const usableHeading =
+ polishedHeading &&
+ !hasClinicalAnswerQualityIssue(polishedHeading) &&
+ !isLowYieldClinicalText(`${polishedHeading}. ${body}`)
+ ? polishedHeading
+ : "";
+ return cleanAnswerSectionHeading(usableHeading, body);
+}
+
function sanitizeAnswerSections(
sections: AnswerSection[] | undefined,
results: SearchResult[],
@@ -769,10 +785,10 @@ function sanitizeAnswerSections(
return (sections ?? [])
.map((section) => {
- const heading = sanitizeStructuredText(section.heading, { minLength: 1, minTokens: 1 });
const body = removeIncompleteTrailingSentence(
- sanitizeStructuredText(section.body, { minLength: 8, minTokens: 2 }),
+ sanitizeAnswerText(section.body) || sanitizeStructuredText(section.body, { minLength: 8, minTokens: 2 }),
);
+ const heading = sanitizeAnswerSectionHeadingText(section.heading, body);
const citation_chunk_ids = [...new Set(section.citation_chunk_ids.filter((id) => allowed.has(id)))];
const citationSources = citation_chunk_ids
.map((id) => allowed.get(id))
@@ -789,6 +805,7 @@ function sanitizeAnswerSections(
if (!section.heading || !section.body || section.citation_chunk_ids.length === 0) return false;
if (!isUsableAnswerSectionText(section.heading, { minTokens: 1, minLength: 1 })) return false;
if (!isUsableAnswerSectionText(section.body, { minTokens: 2, minLength: 8 })) return false;
+ if (hasClinicalAnswerQualityIssue(section.heading) || hasClinicalAnswerQualityIssue(section.body)) return false;
if (isLowYieldClinicalText(`${section.heading}. ${section.body}`)) return false;
const key = `${section.heading.toLowerCase()}||${section.body.toLowerCase()}`;
if (seen.has(key)) return false;
@@ -1184,7 +1201,10 @@ function stableHash(value: string) {
}
export function retrievalPlanCacheQuery(
- args: Pick,
+ args: Pick<
+ SearchChunksArgs,
+ "query" | "documentId" | "documentIds" | "ownerId" | "queryMode" | "topK" | "minSimilarity"
+ >,
queryClass?: RagQueryClass,
queryVariants: string[] = [],
) {
@@ -1481,8 +1501,9 @@ export function invalidateRagCachesForOwner(ownerId?: string | null) {
void (async () => {
try {
await createAdminClient().from("rag_response_cache").delete().in("cache_kind", ["search", "answer"]);
- } catch {
+ } catch (error) {
// Shared cache invalidation is best effort.
+ console.warn("Shared cache invalidation failed (all kinds):", error);
}
})();
return;
@@ -1506,8 +1527,9 @@ export function invalidateRagCachesForOwner(ownerId?: string | null) {
.delete()
[sharedCacheOwnerId ? "eq" : "is"]("owner_id", sharedCacheOwnerId)
.in("cache_kind", ["search", "answer"]);
- } catch {
+ } catch (error) {
// Shared cache invalidation is best effort.
+ console.warn("Shared cache invalidation failed for owner:", error);
}
})();
}
@@ -1520,8 +1542,9 @@ function invalidateAnonymousSharedRagCaches() {
.delete()
.is("owner_id", null)
.in("cache_kind", ["search", "answer"]);
- } catch {
+ } catch (error) {
// Shared cache invalidation is best effort.
+ console.warn("Shared cache invalidation failed for anonymous:", error);
}
})();
}
@@ -2187,7 +2210,7 @@ async function loadChunksForMemoryCards(
const { data: chunks, error: chunksError } = await supabase
.from("document_chunks")
.select(
- "id,document_id,page_number,chunk_index,section_heading,section_path,heading_level,parent_heading,anchor_id,content,retrieval_synopsis,image_ids",
+ "id,document_id,page_number,chunk_index,section_heading,section_path,heading_level,parent_heading,anchor_id,content,retrieval_synopsis,image_ids,index_generation_id",
)
.in("id", chunkIds)
.limit(chunkIds.length);
@@ -2216,6 +2239,8 @@ async function loadChunksForMemoryCards(
.map((chunk) => {
const document = documentById.get(chunk.document_id);
if (!document) return null;
+ const committedGeneration = committedIndexGeneration(document.metadata);
+ if (chunk.index_generation_id && chunk.index_generation_id !== committedGeneration) return null;
const card = bestCardByChunk.get(chunk.id);
const similarity = Math.min(0.92, 0.58 + (card?.confidence ?? 0.5) * 0.28);
return {
@@ -2259,7 +2284,7 @@ async function loadChunksForSignalMatches(args: {
const { data: chunks, error: chunksError } = await args.supabase
.from("document_chunks")
.select(
- "id,document_id,page_number,chunk_index,section_heading,section_path,heading_level,parent_heading,anchor_id,content,retrieval_synopsis,image_ids",
+ "id,document_id,page_number,chunk_index,section_heading,section_path,heading_level,parent_heading,anchor_id,content,retrieval_synopsis,image_ids,index_generation_id",
)
.in("id", chunkIds)
.limit(chunkIds.length);
@@ -2281,6 +2306,8 @@ async function loadChunksForSignalMatches(args: {
const document = documentById.get(chunk.document_id);
const match = bestMatchByChunk.get(chunk.id);
if (!document || !match) return null;
+ const committedGeneration = committedIndexGeneration(document.metadata);
+ if (chunk.index_generation_id && chunk.index_generation_id !== committedGeneration) return null;
return {
id: chunk.id,
document_id: chunk.document_id,
@@ -2616,7 +2643,7 @@ async function packAdjacentSourceContext(
try {
const { data, error } = await supabase
.from("document_chunks")
- .select("id,document_id,page_number,chunk_index,section_heading,content,retrieval_synopsis")
+ .select("id,document_id,page_number,chunk_index,section_heading,content,retrieval_synopsis,index_generation_id")
.in("document_id", documentIds)
.in("chunk_index", chunkIndexes)
.order("chunk_index", { ascending: true })
@@ -2628,7 +2655,12 @@ async function packAdjacentSourceContext(
string,
{ id: string; section_heading: string | null; content: string; retrieval_synopsis?: string | null }
>();
+ const committedGenerationByDocument = new Map(
+ targetResults.map((result) => [result.document_id, committedIndexGeneration(result.source_metadata)] as const),
+ );
for (const chunk of data) {
+ const committedGeneration = committedGenerationByDocument.get(chunk.document_id);
+ if (chunk.index_generation_id && chunk.index_generation_id !== committedGeneration) continue;
chunksByDocumentAndIndex.set(`${chunk.document_id}:${chunk.chunk_index}`, {
id: chunk.id,
section_heading: chunk.section_heading,
@@ -2716,11 +2748,22 @@ async function attachPageVisualEvidence(
const data = [...(pageData.data ?? []), ...(directData.data ?? [])];
if ((pageData.error && directData.error) || data.length === 0) return results;
+ const committedGenerationByDocument = new Map(
+ results.map((result) => [result.document_id, committedIndexGeneration(result.source_metadata)] as const),
+ );
const imagesByPage = new Map();
const imagesById = new Map();
for (const image of data) {
if (imagesById.has(image.id)) continue;
const metadata = safeRecord(image.metadata);
+ if (
+ !isCommittedGenerationMetadata({
+ rowMetadata: metadata,
+ committedGeneration: committedGenerationByDocument.get(image.document_id),
+ })
+ ) {
+ continue;
+ }
const rawTableText = metadataText(metadata, "table_text");
const tableText = metadataText(metadata, "table_text_snippet") ?? rawTableText;
const publicImage: ChunkImage = {
@@ -2801,10 +2844,7 @@ export function decideTextFastPath(
) {
return { returnFastPath: false, reason: "missing_structured_threshold_evidence" };
}
- if (
- queryClass === "table_threshold" &&
- /\b(?:withhold|withheld|withholding|cease|stop|stopped)\b/i.test(query)
- ) {
+ if (queryClass === "table_threshold" && /\b(?:withhold|withheld|withholding|cease|stop|stopped)\b/i.test(query)) {
return { returnFastPath: false, reason: "threshold_action_requires_structured_retrieval" };
}
if (queryClass === "medication_dose_risk" && !results.slice(0, 5).some((result) => hasDoseEvidenceSupport(result))) {
@@ -2915,7 +2955,10 @@ function hasDirectSourceImageEvidence(result: SearchResult) {
}
function sourceImageRequiredForQuery(query: string) {
- return /\b(?:show|display|attach|open|view|source|original)\b/i.test(query) && /\b(?:image|table|chart|figure|crop|visual)\b/i.test(query);
+ return (
+ /\b(?:show|display|attach|open|view|source|original)\b/i.test(query) &&
+ /\b(?:image|table|chart|figure|crop|visual)\b/i.test(query)
+ );
}
function directTitleOrAliasSupport(query: string, results: SearchResult[]) {
@@ -2975,7 +3018,10 @@ export function evaluateEvidenceCoverageGate(
/\b(?:withhold|withheld|withholding|cease|ceased|stop|stopped)\b/i.test(query)
) {
const hasBlood = hasAnyTerm(evidenceText, /\b(?:anc|fbc|wbc|neutrophil|neutrophils|full blood)\b/i);
- const hasAction = hasAnyTerm(evidenceText, /\b(?:withhold|withheld|withholding|cease|ceased|stop|stopped|red)\b/i);
+ const hasAction = hasAnyTerm(
+ evidenceText,
+ /\b(?:withhold|withheld|withholding|cease|ceased|stop|stopped|red)\b/i,
+ );
return {
accepted: hasStructuredThreshold && hasBlood && hasAction,
reason:
@@ -2993,7 +3039,9 @@ export function evaluateEvidenceCoverageGate(
hasAnyTerm(evidenceText, /\bproperty\b/i) &&
hasAnyTerm(evidenceText, /\b(?:restricted|prohibited|contraband|items?)\b/i);
return {
- accepted: hasPropertyTerms && (hasStructuredThreshold || sourceImageSatisfied || hasVisualUnit || strongestScore >= 0.62),
+ accepted:
+ hasPropertyTerms &&
+ (hasStructuredThreshold || sourceImageSatisfied || hasVisualUnit || strongestScore >= 0.62),
reason: hasPropertyTerms ? "patient_property_restricted_items_gate" : "missing_patient_property_terms",
strategy: "text_fast_path",
sourceImageRequired,
@@ -3273,6 +3321,105 @@ function cleanExtractivePointText(value: string) {
const extractiveClinicalDirectivePattern =
/\b(?:arrange|assess|cease|check|complete|contact|continue|discontinue|escalate|notify|prescribe|record|refer|report|review|stop|withhold|must|required|requires?|should)\b/i;
+const extractiveQueryStopwords = new Set([
+ "a",
+ "an",
+ "and",
+ "are",
+ "about",
+ "be",
+ "by",
+ "can",
+ "do",
+ "does",
+ "for",
+ "from",
+ "how",
+ "in",
+ "is",
+ "it",
+ "of",
+ "on",
+ "or",
+ "the",
+ "to",
+ "what",
+ "when",
+ "where",
+ "which",
+ "who",
+ "why",
+ "should",
+ "dose",
+ "dosing",
+ "dosage",
+ "medication",
+ "medicine",
+ "monitoring",
+ "monitor",
+ "baseline",
+ "tests",
+ "result",
+ "results",
+ "pathway",
+ "referral",
+ "patient",
+ "patients",
+ "clinical",
+ "advice",
+ "contraindication",
+ "contraindications",
+ "please",
+]);
+const extractiveTruncationPattern =
+ /\b(?:stabili[sz]e\s+the\s+do|the\s+do\b|liver\s+functi\b|respiratio\b|if\s+a\s+60%\s+decrease\s+in\s+b\b)\b/i;
+const extractiveProductCataloguePattern =
+ /\b(?:Lithicarb|Quilonum\s+SR|Campral|imprest\s+location|formulary\s+one)\b|[®™]/i;
+
+function extractiveQueryTokens(query: string) {
+ return splitBalancedWords(query).filter((token) => token.length > 2 && !extractiveQueryStopwords.has(token));
+}
+
+function escapeQueryToken(value: string) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function queryTokenVariants(token: string) {
+ const variants = new Set([token]);
+ if (token.length > 5 && token.endsWith("ing")) variants.add(token.slice(0, -3));
+ if (token.length > 4 && token.endsWith("ies")) variants.add(`${token.slice(0, -3)}y`);
+ if (token.length > 4 && token.endsWith("es")) variants.add(token.slice(0, -2));
+ if (token.length > 4 && token.endsWith("s")) variants.add(token.slice(0, -1));
+ return [...variants].filter((variant) => variant.length > 2);
+}
+
+function queryTokenMatchesText(token: string, text: string) {
+ if (token === "ect") return /\b(?:ect|electroconvulsive)\b/i.test(text);
+ for (const variant of queryTokenVariants(token)) {
+ const pattern =
+ variant.length <= 3
+ ? new RegExp(`\\b${escapeQueryToken(variant)}\\b`, "i")
+ : new RegExp(`\\b${escapeQueryToken(variant)}\\w*\\b`, "i");
+ if (pattern.test(text)) return true;
+ }
+ return false;
+}
+
+function hasRelevantQueryOverlap(text: string, query: string) {
+ const tokens = extractiveQueryTokens(query);
+ if (!tokens.length) return true;
+ return tokens.some((token) => queryTokenMatchesText(token, text));
+}
+
+function hasBadExtractiveQuality(text: string) {
+ const normalized = normalizeSectionText(text);
+ if (!normalized) return true;
+ if (extractiveTruncationPattern.test(normalized)) return true;
+ if (extractiveProductCataloguePattern.test(normalized)) return true;
+ if (hasClinicalAnswerQualityIssue(normalized)) return true;
+ if (/\btable\s+\d+\b/i.test(normalized) && normalized.length > 180) return true;
+ return false;
+}
function isLowValueExtractiveCaption(clause: string) {
const descriptor =
@@ -3284,11 +3431,18 @@ function isLowValueExtractiveCaption(clause: string) {
}
function sourcePointClauses(value: string, query: string) {
- const tokens = splitBalancedWords(query).filter((token) => token.length > 3);
+ const tokens = extractiveQueryTokens(query);
const clauses = cleanExtractivePointText(value)
.split(/(?<=[.!?])\s+|\s+[•]\s+|\s+\|\s+/)
.map((clause) => cleanExtractivePointText(clause))
- .filter((clause) => clause.length >= 18 && !looksLikeJsonArtifact(clause) && !isLowValueExtractiveCaption(clause));
+ .filter(
+ (clause) =>
+ clause.length >= 18 &&
+ !looksLikeJsonArtifact(clause) &&
+ !isLowValueExtractiveCaption(clause) &&
+ !hasBadExtractiveQuality(clause) &&
+ hasRelevantQueryOverlap(clause, query),
+ );
return clauses
.map((clause, index) => {
@@ -3425,30 +3579,9 @@ function quoteToExtractivePoint(quote: QuoteCard, query: string): ExtractiveAnsw
};
}
-function naturalAnswerLead(query: string, queryClass: RagQueryClass, points: ExtractiveAnswerPoint[]) {
- if (/\bclozapine\b/i.test(query) && /\bmonitor/i.test(query)) {
- return "The retrieved clozapine sources support a monitoring-focused answer, but the selected excerpts are strongest for specific source points rather than a full monitoring schedule.";
- }
- if (queryClass === "medication_dose_risk") {
- return "The retrieved medication/risk sources support these practical points.";
- }
- if (queryClass === "table_threshold") {
- return "The retrieved table or threshold evidence supports these points.";
- }
- if (points.length === 1) return "The strongest retrieved source supports this point.";
- return "The strongest retrieved sources support these points.";
-}
-
function formatNaturalPoint(point: ExtractiveAnswerPoint) {
- const text = cleanExtractivePointText(point.text).replace(/[.;,\s]+$/, "");
+ const text = sanitizeAnswerText(cleanExtractivePointText(point.text)).replace(/[.;,\s]+$/, "");
if (!text) return "";
- if (
- /^(the|this|these|there|monitor|review|ensure|commence|copy|prescribe|annual|time since|blood test)\b/i.test(text)
- ) {
- return `${text}.`;
- }
- if (point.label === "Monitoring") return `Monitoring evidence: ${text}.`;
- if (point.label === "Dose detail") return `Dose evidence: ${text}.`;
return `${text}.`;
}
@@ -3460,27 +3593,21 @@ function buildNaturalExtractiveAnswer(args: {
}) {
const points = uniqueExtractivePoints(args.points, 3);
if (!points.length) {
+ const gapAnswer = finalQualityGapAnswer(args.query, args.queryClass);
return {
- answer:
- "The indexed source passages matched the question, but no concise source sentence could be extracted. Open the cited sources before relying on this result.",
- body: "No concise source sentence could be extracted from the selected passages. Use the linked citations to inspect the source text.",
+ answer: gapAnswer,
+ body: gapAnswer,
citationChunkIds: [] as string[],
};
}
- const lead = naturalAnswerLead(args.query, args.queryClass, points);
const pointSentences = points.map(formatNaturalPoint).filter(Boolean);
- const caveat =
- args.queryClass === "medication_dose_risk" && /\bmonitor/i.test(args.query)
- ? "If you need the complete monitoring schedule, open the linked source pages and check the surrounding table or section."
- : "";
- const answer = [lead, ...pointSentences, caveat].filter(Boolean).join(" ");
- const body = [lead, ...pointSentences].filter(Boolean).join(" ");
+ const answer = sanitizeAnswerText(pointSentences.join(" "));
return {
answer: boldHighYieldClinicalText(answer, args.query),
- body: boldHighYieldClinicalText(body, args.query),
- citationChunkIds: Array.from(new Set(points.flatMap((point) => point.citationChunkIds))),
+ body: boldHighYieldClinicalText(answer, args.query),
+ citationChunkIds: answer ? Array.from(new Set(points.flatMap((point) => point.citationChunkIds))) : [],
};
}
@@ -3563,15 +3690,7 @@ function buildExtractiveAnswer(args: {
routingReason: args.routeReason,
queryClass: args.queryClass,
latencyTimings: args.timings,
- answerSections: naturalAnswer.citationChunkIds.length
- ? [
- {
- heading: "Direct source-backed answer",
- body: naturalAnswer.body,
- citation_chunk_ids: naturalAnswer.citationChunkIds,
- },
- ]
- : [],
+ answerSections: [],
quoteCards,
visualEvidence: args.visualEvidence,
bestSource: args.bestSource,
@@ -3656,6 +3775,148 @@ function isEssentialSimpleQuestionSection(section: Pick {
+ const body = sanitizeAnswerText(section.body);
+ if (!body || hasClinicalAnswerQualityIssue(body) || isLowYieldClinicalText(body)) return null;
+ const bodyKey = normalizeSectionText(body).toLowerCase();
+ if (bodyKey === answerKey || answerKey.includes(bodyKey) || bodyKey.includes(answerKey)) return null;
+ const heading = cleanAnswerSectionHeading(section.heading, body);
+ return {
+ ...section,
+ heading,
+ body: boldHighYieldClinicalText(body, query),
+ kind: section.kind ?? sectionHeadingKind(heading),
+ supportLevel: section.supportLevel ?? "direct",
+ } satisfies AnswerSection;
+ })
+ .filter((section): section is Exclude => Boolean(section));
+
+ return applyNumericVerification({
+ ...answer,
+ answer: boldHighYieldClinicalText(cleanedAnswer, query),
+ answerSections,
+ });
+}
+
export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
assertGlobalSearchAllowed(args);
const supabase = createAdminClient();
@@ -3910,9 +4171,14 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
telemetry.memory_top_score ?? 0,
...memoryBoost.cards.map(memoryCardChunkScore),
);
- recordRetrievalLayer(telemetry, "memory_cards", Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length), {
- topScore: Math.max(telemetry.memory_top_score ?? 0, ...memoryBoost.cards.map(memoryCardChunkScore)),
- });
+ recordRetrievalLayer(
+ telemetry,
+ "memory_cards",
+ Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length),
+ {
+ topScore: Math.max(telemetry.memory_top_score ?? 0, ...memoryBoost.cards.map(memoryCardChunkScore)),
+ },
+ );
let documentLookupResults = await attachPageVisualEvidence(
supabase,
diversifySearchResults(
@@ -4341,6 +4607,7 @@ export function parseAnswerJson(raw: string, results: SearchResult[], query?: st
const parsedAnswer = parsed.answer ?? "";
const nonArtifactParsedAnswer = parsedAnswer.trim() && !looksLikeJsonArtifact(parsedAnswer) ? parsedAnswer : "";
const sanitizedAnswer =
+ sanitizeAnswerText(parsedAnswer) ||
sanitizeStructuredText(parsedAnswer, { minLength: 8, minTokens: 2 }) ||
nonArtifactParsedAnswer ||
machineReadableFallbackAnswer;
@@ -4365,7 +4632,8 @@ export function parseAnswerJson(raw: string, results: SearchResult[], query?: st
}
// GEN-C2 / GEN-H2: numeric faithfulness gate.
return applyNumericVerification(answer);
- } catch {
+ } catch (error) {
+ console.warn("Failed to parse answer payload, falling back to safe text:", error);
return safeFallbackAnswer(raw, results, query);
}
}
@@ -4750,18 +5018,20 @@ export async function answerQuestionWithScope(args: {
retrievalDiagnostics,
);
+ const finalizedAnswer = finalizeRagAnswerQuality(answer, args.query, queryClass);
+
if (args.logQuery !== false)
await logRagQuery({
owner_id: args.ownerId ?? null,
query: args.query,
- answer: answer.answer,
+ answer: finalizedAnswer.answer,
source_chunk_ids: answerInputResults.map((result) => result.id),
model: null,
metadata: {
document_id: args.documentId ?? null,
document_ids: args.documentIds ?? null,
- grounded: answer.grounded,
- confidence: answer.confidence,
+ grounded: finalizedAnswer.grounded,
+ confidence: finalizedAnswer.confidence,
routing_mode: route.mode,
routing_reason: route.reason,
query_class: queryClass,
@@ -4774,8 +5044,8 @@ export async function answerQuestionWithScope(args: {
...scoreLogMetadata,
...searchTelemetryDecisionMetadata(),
cited_chunk_count: 0,
- quote_count: answer.quoteCards?.length ?? 0,
- visual_evidence_count: answer.visualEvidence?.length ?? 0,
+ quote_count: finalizedAnswer.quoteCards?.length ?? 0,
+ visual_evidence_count: finalizedAnswer.visualEvidence?.length ?? 0,
search_cache_hit: search.telemetry.search_cache_hit,
text_fast_path_latency_ms: search.telemetry.text_fast_path_latency_ms,
embedding_skipped: search.telemetry.embedding_skipped,
@@ -4788,16 +5058,16 @@ export async function answerQuestionWithScope(args: {
rrf_top_score: search.telemetry.rrf_top_score,
search_latency_ms: searchLatencyMs,
generation_latency_ms: 0,
- total_latency_ms: answer.latencyTimings?.total_latency_ms ?? searchLatencyMs,
- evidence_summary: answer.evidenceSummary,
- source_coverage: answer.sourceCoverage,
- ...retrievalLogMetadata(answer.retrievalDiagnostics ?? retrievalDiagnostics),
+ total_latency_ms: finalizedAnswer.latencyTimings?.total_latency_ms ?? searchLatencyMs,
+ evidence_summary: finalizedAnswer.evidenceSummary,
+ source_coverage: finalizedAnswer.sourceCoverage,
+ ...retrievalLogMetadata(finalizedAnswer.retrievalDiagnostics ?? retrievalDiagnostics),
related_document_count: relatedDocuments.length,
},
});
- setCachedAnswer(args, answer);
- return answer;
+ setCachedAnswer(args, finalizedAnswer);
+ return finalizedAnswer;
}
if (route.mode === "extractive") {
@@ -4848,23 +5118,24 @@ export async function answerQuestionWithScope(args: {
answer.smartPanel = answer.smartPanel ? { ...answer.smartPanel, relevance } : answer.smartPanel;
answer.smartApiPlan = smartApiPlan;
answer.scoreExplanations = answerScoreExplanations;
+ const finalizedAnswer = finalizeRagAnswerQuality(answer, args.query, queryClass);
if (args.logQuery !== false)
await logRagQuery({
owner_id: args.ownerId ?? null,
query: args.query,
- answer: answer.answer,
+ answer: finalizedAnswer.answer,
source_chunk_ids: answerInputResults.map((result) => result.id),
model: null,
metadata: {
document_id: args.documentId ?? null,
document_ids: args.documentIds ?? null,
- grounded: answer.grounded,
- confidence: answer.confidence,
- routing_mode: answer.routingMode,
- routing_reason: answer.routingReason,
+ grounded: finalizedAnswer.grounded,
+ confidence: finalizedAnswer.confidence,
+ routing_mode: finalizedAnswer.routingMode,
+ routing_reason: finalizedAnswer.routingReason,
query_class: queryClass,
- fallback_reason: fallbackReasonFromRouting(answer.routingReason),
+ fallback_reason: fallbackReasonFromRouting(finalizedAnswer.routingReason),
model_used: null,
retrieved_candidate_count: results.length,
...smartApiLogMetadata(smartApiPlan),
@@ -4872,11 +5143,11 @@ export async function answerQuestionWithScope(args: {
...memoryLogMetadata,
...scoreLogMetadata,
...searchTelemetryDecisionMetadata(),
- cited_chunk_count: answer.citations.length,
- quote_count: answer.quoteCards?.length ?? 0,
- visual_evidence_count: answer.visualEvidence?.length ?? 0,
+ cited_chunk_count: finalizedAnswer.citations.length,
+ quote_count: finalizedAnswer.quoteCards?.length ?? 0,
+ visual_evidence_count: finalizedAnswer.visualEvidence?.length ?? 0,
related_document_count: relatedDocuments.length,
- ...retrievalLogMetadata(answer.retrievalDiagnostics ?? retrievalDiagnostics),
+ ...retrievalLogMetadata(finalizedAnswer.retrievalDiagnostics ?? retrievalDiagnostics),
search_cache_hit: search.telemetry.search_cache_hit,
text_fast_path_latency_ms: search.telemetry.text_fast_path_latency_ms,
embedding_skipped: search.telemetry.embedding_skipped,
@@ -4889,14 +5160,14 @@ export async function answerQuestionWithScope(args: {
rrf_top_score: search.telemetry.rrf_top_score,
search_latency_ms: searchLatencyMs,
generation_latency_ms: 0,
- total_latency_ms: answer.latencyTimings?.total_latency_ms ?? Date.now() - startedAt,
- evidence_summary: answer.evidenceSummary,
- source_coverage: answer.sourceCoverage,
+ total_latency_ms: finalizedAnswer.latencyTimings?.total_latency_ms ?? Date.now() - startedAt,
+ evidence_summary: finalizedAnswer.evidenceSummary,
+ source_coverage: finalizedAnswer.sourceCoverage,
},
});
- setCachedAnswer(args, answer);
- return answer;
+ setCachedAnswer(args, finalizedAnswer);
+ return finalizedAnswer;
}
const answerInstructions = `You are answering for a psychiatrist in Perth, Australia using only uploaded clinical document excerpts.
@@ -4908,6 +5179,7 @@ Rules:
- Start the answer field with the direct clinical answer in the first sentence. Keep only the vital and most relevant information there.
- First, silently interpret what the clinician is really asking: clinical task, population/scope, likely decision point, urgency/risk, and whether they need a pathway, threshold, comparison, or document lookup. Use that interpretation to shape the answer.
- Write like a clinician who has read the source material and is explaining the logical clinical approach. Avoid template language, source-inventory wording, and generic phrases such as "the strongest retrieved sources support", "source-backed", "the source states", or "based on the provided excerpts".
+- Use polished sentence case in prose. Do not copy source title casing, all-caps headings, product catalogue lines, brand lists, imprest/formulary labels, or source section headings into the answer field.
- For broad management, treatment, care, pathway, or approach questions, organize the synthesis naturally: immediate risk/specialist referral if supported, core first-line intervention, adjunctive medication or monitoring when supported, special populations, and important gaps. Do not dump every treatment option with equal weight.
- For simple definition or direct fact questions, answer only the direct question. Do not broaden into management, treatment, monitoring, or pathway content unless the user explicitly asks for it. Return no answerSections unless one source-gap or safety caveat is essential.
- Use model-generated clinical synthesis by default; do not stitch disconnected source quotes into the answer.
@@ -4922,6 +5194,7 @@ Rules:
- Use thresholds for numeric cutoffs, ranges, score boundaries, withhold/stop criteria, or table-like criteria. Use comparison for source differences, conflicting guidance, or when the query asks "compare", "versus", or "difference".
- Omit sections that are not supported by the retrieved excerpts.
- Do not include low-yield provenance in answer or answerSections: no document IDs, procedure codes, page labels, file names, chunk numbers, similarity scores, source metadata, headers, footers, review tables, or document-control text.
+- Do not include source footnote markers or trailing citation digits in prose, such as "Tests1" or "months.1"; citation links belong only in the structured citations.
- Keep provenance only in citations and quoteCards via chunk IDs. If source titles or page numbers are useful, leave them to the UI citations rather than writing them in prose.
- Be concise: usually 1-3 short sentences in the answer field and about 35-75 words. Use answerSections for extra detail instead of lengthening the answer field.
- Prefer Australian or WA-specific guidance when present in the sources.
@@ -4940,6 +5213,7 @@ Rules:
- Treat the fused source brief as an orientation layer only. Verify every claim against the raw source excerpts below it.
- Structured memory lines are indexing-time source facts mapped back to source chunks. Use them to focus the answer, but cite the original chunks.
- Start with the direct answer. Omit tangential background, administrative details, source titles, file names, page labels, and provenance from the answer field even when they appear in retrieved sources.
+- Never start an answer by listing available products or formulations unless the user specifically asks what formulations exist. If a formulation matters clinically, mention only the clinically relevant formulation in normal sentence case.
- Bold only source-supported high-yield details using **bold**: medications, thresholds, timing, escalation triggers, required actions, contraindications, and terms central to the question.
- Do not bold whole sentences or routine filler wording.
- Do not use Markdown other than **bold** inside answer or answerSections.
@@ -5297,6 +5571,7 @@ ${qualityRetryInstruction}`
...retrievalDiagnostics,
routeMode: answer.routingMode ?? retrievalDiagnostics.routeMode,
});
+ answer = finalizeRagAnswerQuality(answer, args.query, queryClass);
if (args.logQuery !== false)
await logRagQuery({
@@ -5362,9 +5637,10 @@ ${qualityRetryInstruction}`
mode: "unsupported",
reason: "generation_fallback",
});
- const fallbackAnswer = annotateAnswerWithDiagnostics(
- await buildGenerationFallbackAnswer(error, relatedDocuments),
- retrievalDiagnostics,
+ const fallbackAnswer = finalizeRagAnswerQuality(
+ annotateAnswerWithDiagnostics(await buildGenerationFallbackAnswer(error, relatedDocuments), retrievalDiagnostics),
+ args.query,
+ queryClass,
);
if (args.logQuery !== false)
await logRagQuery({
@@ -5437,13 +5713,19 @@ export async function summarizeDocument(documentId: string, ownerId?: string) {
const { data: chunks, error } = await supabase
.from("document_chunks")
- .select("id,document_id,page_number,chunk_index,section_heading,content,retrieval_synopsis,image_ids")
+ .select(
+ "id,document_id,page_number,chunk_index,section_heading,content,retrieval_synopsis,image_ids,index_generation_id",
+ )
.eq("document_id", documentId)
.order("chunk_index", { ascending: true })
.limit(40);
if (error) throw new Error(error.message);
- if (!chunks?.length) {
+ const committedGeneration = committedIndexGeneration((document as { metadata?: unknown }).metadata);
+ const committedChunks = (chunks ?? []).filter(
+ (chunk) => !chunk.index_generation_id || chunk.index_generation_id === committedGeneration,
+ );
+ if (!committedChunks.length) {
return {
answer: "This document has not been indexed yet, so no summary can be generated.",
grounded: false,
@@ -5453,7 +5735,7 @@ export async function summarizeDocument(documentId: string, ownerId?: string) {
} satisfies RagAnswer;
}
- const results = chunks.map((chunk) => ({
+ const results = committedChunks.map((chunk) => ({
...chunk,
title: document.title,
file_name: document.file_name,
diff --git a/src/lib/reindex-pipeline.ts b/src/lib/reindex-pipeline.ts
index 1a10710d6c..0468c0eedd 100644
--- a/src/lib/reindex-pipeline.ts
+++ b/src/lib/reindex-pipeline.ts
@@ -21,3 +21,24 @@ export function hasIncompleteDocumentsWithoutOpenJobs(snapshot: ReindexQueueSnap
(snapshot.processingDocuments > 0 || snapshot.failedDocuments > 0)
);
}
+
+export function metadataRecord(metadata: unknown): Record {
+ return metadata && typeof metadata === "object" && !Array.isArray(metadata)
+ ? { ...(metadata as Record) }
+ : {};
+}
+
+export function committedIndexGeneration(metadata: unknown) {
+ const generation = metadataRecord(metadata).index_generation_id;
+ return typeof generation === "string" && generation.trim() ? generation.trim() : null;
+}
+
+export function isAtomicReindexCandidate(document: { status?: string | null; metadata?: unknown }) {
+ return document.status === "indexed";
+}
+
+export function isCommittedGenerationMetadata(args: { rowMetadata?: unknown; committedGeneration?: string | null }) {
+ const rowGeneration = committedIndexGeneration(args.rowMetadata);
+ if (!rowGeneration) return true;
+ return Boolean(args.committedGeneration) && rowGeneration === args.committedGeneration;
+}
diff --git a/src/lib/search-scope.ts b/src/lib/search-scope.ts
index 275f534b42..df977b931a 100644
--- a/src/lib/search-scope.ts
+++ b/src/lib/search-scope.ts
@@ -193,7 +193,9 @@ export async function resolveSearchScope(args: {
documentQuery = documentQuery.or(orParts.join(","));
}
if (filters.extractionQualities?.length) {
- const orParts = filters.extractionQualities.map((q) => `metadata->>extraction_quality.eq.${normalizeFilterText(q)}`);
+ const orParts = filters.extractionQualities.map(
+ (q) => `metadata->>extraction_quality.eq.${normalizeFilterText(q)}`,
+ );
if (filters.extractionQualities.includes("unknown")) orParts.push("metadata->>extraction_quality.is.null");
documentQuery = documentQuery.or(orParts.join(","));
}
@@ -201,8 +203,8 @@ export async function resolveSearchScope(args: {
documentQuery = documentQuery.in("import_batch_id", filters.importBatchIds);
}
if (filters.collections?.length) {
- const orParts = filters.collections.map((collection) =>
- `metadata->>collection.ilike.${escapePostgrestValue(normalizeFilterText(collection))}`,
+ const orParts = filters.collections.map(
+ (collection) => `metadata->>collection.ilike.${escapePostgrestValue(normalizeFilterText(collection))}`,
);
documentQuery = documentQuery.or(orParts.join(","));
}
diff --git a/src/lib/supabase/client.tsx b/src/lib/supabase/client.tsx
index 28cc1ac823..57ad334bff 100644
--- a/src/lib/supabase/client.tsx
+++ b/src/lib/supabase/client.tsx
@@ -24,11 +24,17 @@ const AuthContext = createContext(null);
let browserSupabaseClient: SupabaseClient | null | undefined;
let browserSupabaseClientConfig: string | null = null;
+export function isUsableBrowserSupabaseKey(key: string | null | undefined): key is string {
+ const value = key?.trim();
+ if (!value) return false;
+ return !/<[^>]+>|^your-|replace-with|placeholder/i.test(value);
+}
+
function createBrowserSupabaseClient() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
- const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
+ const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY?.trim();
- if (!url || !key) {
+ if (!url || !isUsableBrowserSupabaseKey(key)) {
browserSupabaseClient = null;
browserSupabaseClientConfig = null;
return null;
@@ -42,13 +48,14 @@ function createBrowserSupabaseClient() {
return null;
}
- const configKey = `${url}:${key}`;
+ const publishableKey: string = key;
+ const configKey = `${url}:${publishableKey}`;
if (browserSupabaseClientConfig === configKey) {
return browserSupabaseClient ?? null;
}
browserSupabaseClientConfig = configKey;
- browserSupabaseClient = createClient(url, key, {
+ browserSupabaseClient = createClient(url, publishableKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
diff --git a/src/lib/supabase/health.ts b/src/lib/supabase/health.ts
index 61da9133ab..e9da034bc2 100644
--- a/src/lib/supabase/health.ts
+++ b/src/lib/supabase/health.ts
@@ -12,8 +12,7 @@ type SupabaseProbeClient = {
};
export type SupabaseHealthResult =
- | { ok: true; checkedAt: string }
- | { ok: false; checkedAt: string; message: string; rawMessage: string };
+ { ok: true; checkedAt: string } | { ok: false; checkedAt: string; message: string; rawMessage: string };
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message;
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 44f6bec339..18c45fe306 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -16,11 +16,7 @@ export type ImageEvidenceCategory =
| "unclear";
export type ClinicalImageUseClass =
- | "clinical_evidence"
- | "administrative"
- | "reference"
- | "decorative_or_empty"
- | "ambiguous";
+ "clinical_evidence" | "administrative" | "reference" | "decorative_or_empty" | "ambiguous";
export type DocumentLabelType =
| "site"
@@ -45,6 +41,11 @@ export type DocumentOrganizationType =
| "checklist"
| "pathway"
| "reference"
+ | "algorithm"
+ | "factsheet"
+ | "manual"
+ | "assessment_tool"
+ | "prescribing_aid"
| "unknown";
export type DocumentOrganizationProfile = {
@@ -764,12 +765,7 @@ export type SmartRagApiPlan = {
};
export type AnswerResponseMode =
- | "checklist"
- | "comparison_matrix"
- | "threshold_table"
- | "clinical_pathway"
- | "document_lookup"
- | "evidence_gap";
+ "checklist" | "comparison_matrix" | "threshold_table" | "clinical_pathway" | "document_lookup" | "evidence_gap";
export type RagAnswer = {
answer: string;
diff --git a/src/lib/visual-intelligence.ts b/src/lib/visual-intelligence.ts
index 75ea808613..47cc3feed2 100644
--- a/src/lib/visual-intelligence.ts
+++ b/src/lib/visual-intelligence.ts
@@ -172,7 +172,7 @@ function normalizedSourceRegion(row: Record, fallback?: Record<
? safeRecord(row.source_region)
: Object.keys(safeRecord(row.bbox)).length
? safeRecord(row.bbox)
- : fallback ?? null;
+ : (fallback ?? null);
}
function withSourceProvenance>(
@@ -373,7 +373,8 @@ export function deterministicStructuredVisualProfile(args: {
for (const column of args.tableColumns ?? []) {
const normalized = column.toLowerCase();
if (/medication|drug/.test(normalized)) tableColumnRoles[column] = "medication";
- else if (/dose|mg|mcg|route/.test(normalized)) tableColumnRoles[column] = /route/.test(normalized) ? "route" : "dose";
+ else if (/dose|mg|mcg|route/.test(normalized))
+ tableColumnRoles[column] = /route/.test(normalized) ? "route" : "dose";
else if (/frequency|schedule/.test(normalized)) tableColumnRoles[column] = "frequency";
else if (/threshold|range|value|level|count/.test(normalized)) tableColumnRoles[column] = "threshold";
else if (/action|management|response|intervention|required/.test(normalized)) tableColumnRoles[column] = "action";
@@ -431,17 +432,22 @@ function visualBudgetClass(args: { sourceKind?: string | null; metadata: Record<
if (args.sourceKind === "table_crop" || /table|threshold/.test(candidate)) return "clinical_table" as const;
if (/flowchart|algorithm|decision|pathway/.test(candidate) || /flowchart|algorithm|next step|decision/.test(text))
return "flowchart" as const;
- if (/risk matrix|matrix/.test(candidate) || /risk matrix|likelihood|consequence/.test(text)) return "risk_matrix" as const;
+ if (/risk matrix|matrix/.test(candidate) || /risk matrix|likelihood|consequence/.test(text))
+ return "risk_matrix" as const;
if (/medication|dose|route/.test(candidate) || /\b(?:dose|route|mg|mcg|im|po)\b/.test(text))
return "medication_chart" as const;
- if (/form|checklist/.test(candidate) || /checklist|tick box|required fields/.test(text)) return "form_checklist" as const;
+ if (/form|checklist/.test(candidate) || /checklist|tick box|required fields/.test(text))
+ return "form_checklist" as const;
if (/graph|chart|axis|trend/.test(candidate) || /graph|chart|axis|trend/.test(text)) return "graph" as const;
return clinicalSignalPattern.test(text) ? ("clinical_region" as const) : ("low_signal" as const);
}
export function scoreVisualCandidate(image: VisualCandidateInput): RankedVisualCandidate {
const metadata = safeRecord(image.metadata);
- const tableText = compact(metadata.table_text ?? metadata.accessible_table_markdown ?? metadata.table_text_snippet, 2400);
+ const tableText = compact(
+ metadata.table_text ?? metadata.accessible_table_markdown ?? metadata.table_text_snippet,
+ 2400,
+ );
const nearbyText = compact(image.nearbyText, 1400);
const text = [metadata.table_title, metadata.table_label, tableText, nearbyText].filter(Boolean).join(" ");
const width = Number(image.width ?? 0);
diff --git a/supabase/functions/indexing-v3-agent/behavior.ts b/supabase/functions/indexing-v3-agent/behavior.ts
index 6080789a2d..a4aeb4012e 100644
--- a/supabase/functions/indexing-v3-agent/behavior.ts
+++ b/supabase/functions/indexing-v3-agent/behavior.ts
@@ -1,61 +1,61 @@
export type CompletionGate = {
counts: {
- sections: number
- memory_cards: number
- generated_labels: number
- index_units: number
- }
+ sections: number;
+ memory_cards: number;
+ generated_labels: number;
+ index_units: number;
+ };
presence: {
- title_embedding: boolean
- summary_embedding: boolean
- }
+ title_embedding: boolean;
+ summary_embedding: boolean;
+ };
quality: {
- extraction_quality: string
- score: number
- }
- missing: string[]
- result: 'complete' | 'deferred'
-}
+ extraction_quality: string;
+ score: number;
+ };
+ missing: string[];
+ result: "complete" | "deferred";
+};
export type CompletionGateRow = {
- sections: number
- memory_cards: number
- generated_labels: number
- index_units: number
- title_embedding: boolean
- summary_embedding: boolean
- quality_extraction_quality: string
- quality_score: number
- missing: string[]
- gate_passed: boolean
-}
+ sections: number;
+ memory_cards: number;
+ generated_labels: number;
+ index_units: number;
+ title_embedding: boolean;
+ summary_embedding: boolean;
+ quality_extraction_quality: string;
+ quality_score: number;
+ missing: string[];
+ gate_passed: boolean;
+};
export type MissingArtifactPlan = {
- needs_sections: boolean
- needs_memory: boolean
- needs_labels: boolean
- needs_index_units: boolean
- needs_title_embedding: boolean
- needs_summary_embedding: boolean
- needs_core_embeddings: boolean
- needs_quality_promotion: boolean
-}
+ needs_sections: boolean;
+ needs_memory: boolean;
+ needs_labels: boolean;
+ needs_index_units: boolean;
+ needs_title_embedding: boolean;
+ needs_summary_embedding: boolean;
+ needs_core_embeddings: boolean;
+ needs_quality_promotion: boolean;
+};
export type DeferralDecision = {
- deferral_count: number
- terminal: boolean
- status: 'deferred' | 'needs_enrichment_artifacts'
- enrichment_status: 'pending' | 'needs_enrichment_artifacts'
- next_run_at: string | null
+ deferral_count: number;
+ terminal: boolean;
+ status: "deferred" | "needs_enrichment_artifacts";
+ enrichment_status: "pending" | "needs_enrichment_artifacts";
+ next_run_at: string | null;
details: {
- code: 'completion_gate_deferred' | 'needs_enrichment_artifacts'
- missing: string[]
- counts: CompletionGate['counts']
- presence: CompletionGate['presence']
- deferral_count: number
- max_deferrals: number
- }
-}
+ code: "completion_gate_deferred" | "needs_enrichment_artifacts";
+ missing: string[];
+ counts: CompletionGate["counts"];
+ presence: CompletionGate["presence"];
+ deferral_count: number;
+ max_deferrals: number;
+ };
+};
export function completionGateFromRow(row: CompletionGateRow): CompletionGate {
return {
@@ -74,28 +74,28 @@ export function completionGateFromRow(row: CompletionGateRow): CompletionGate {
score: row.quality_score,
},
missing: row.missing,
- result: row.gate_passed ? 'complete' : 'deferred',
- }
+ result: row.gate_passed ? "complete" : "deferred",
+ };
}
export function missingArtifactPlan(gate: CompletionGate): MissingArtifactPlan {
- const missing = new Set(gate.missing)
- const needsTitle = missing.has('title_embedding')
- const needsSummary = missing.has('summary_embedding')
+ const missing = new Set(gate.missing);
+ const needsTitle = missing.has("title_embedding");
+ const needsSummary = missing.has("summary_embedding");
return {
- needs_sections: missing.has('sections'),
- needs_memory: missing.has('memory_cards'),
- needs_labels: missing.has('generated_labels'),
- needs_index_units: missing.has('index_units'),
+ needs_sections: missing.has("sections"),
+ needs_memory: missing.has("memory_cards"),
+ needs_labels: missing.has("generated_labels"),
+ needs_index_units: missing.has("index_units"),
needs_title_embedding: needsTitle,
needs_summary_embedding: needsSummary,
needs_core_embeddings: needsTitle || needsSummary,
- needs_quality_promotion: gate.result === 'complete' && gate.quality.extraction_quality !== 'good',
- }
+ needs_quality_promotion: gate.result === "complete" && gate.quality.extraction_quality !== "good",
+ };
}
export function shouldRunVisualArtifacts(args: { eligible_images: number; generated_visual_units: number }): boolean {
- return args.eligible_images > 0 && args.generated_visual_units === 0
+ return args.eligible_images > 0 && args.generated_visual_units === 0;
}
export function metadataNumber(
@@ -103,35 +103,35 @@ export function metadataNumber(
key: string,
fallback = 0,
): number {
- const value = Number(metadata?.[key])
- return Number.isFinite(value) ? value : fallback
+ const value = Number(metadata?.[key]);
+ return Number.isFinite(value) ? value : fallback;
}
export function deferralDecision(args: {
- metadata: Record | null | undefined
- gate: CompletionGate
- maxDeferrals: number
- nowMs: number
+ metadata: Record | null | undefined;
+ gate: CompletionGate;
+ maxDeferrals: number;
+ nowMs: number;
}): DeferralDecision {
- const deferralCount = metadataNumber(args.metadata, 'indexing_v3_agent_deferral_count') + 1
- const terminal = deferralCount >= args.maxDeferrals || args.gate.missing.includes('sections')
- const status = terminal ? 'needs_enrichment_artifacts' : 'deferred'
+ const deferralCount = metadataNumber(args.metadata, "indexing_v3_agent_deferral_count") + 1;
+ const terminal = deferralCount >= args.maxDeferrals || args.gate.missing.includes("sections");
+ const status = terminal ? "needs_enrichment_artifacts" : "deferred";
const nextRunAt = terminal
? null
- : new Date(args.nowMs + Math.min(24 * 60 * 60_000, 15 * 60_000 * deferralCount)).toISOString()
+ : new Date(args.nowMs + Math.min(24 * 60 * 60_000, 15 * 60_000 * deferralCount)).toISOString();
return {
deferral_count: deferralCount,
terminal,
status,
- enrichment_status: terminal ? 'needs_enrichment_artifacts' : 'pending',
+ enrichment_status: terminal ? "needs_enrichment_artifacts" : "pending",
next_run_at: nextRunAt,
details: {
- code: terminal ? 'needs_enrichment_artifacts' : 'completion_gate_deferred',
+ code: terminal ? "needs_enrichment_artifacts" : "completion_gate_deferred",
missing: args.gate.missing,
counts: args.gate.counts,
presence: args.gate.presence,
deferral_count: deferralCount,
max_deferrals: args.maxDeferrals,
},
- }
+ };
}
diff --git a/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql b/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql
new file mode 100644
index 0000000000..a914cd2403
--- /dev/null
+++ b/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql
@@ -0,0 +1,261 @@
+alter table public.document_chunks
+ drop constraint if exists document_chunks_document_id_chunk_index_key;
+
+create unique index if not exists document_chunks_document_generation_chunk_idx
+ on public.document_chunks(document_id, index_generation_id, chunk_index)
+ where index_generation_id is not null;
+
+create or replace function public.is_committed_document_generation(
+ row_generation uuid,
+ document_metadata jsonb
+)
+returns boolean
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ select row_generation is null
+ or row_generation::text = nullif(coalesce(document_metadata, '{}'::jsonb)->>'index_generation_id', '');
+$$;
+
+create or replace function public.is_committed_artifact_generation(
+ artifact_metadata jsonb,
+ document_metadata jsonb
+)
+returns boolean
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ select nullif(coalesce(artifact_metadata, '{}'::jsonb)->>'index_generation_id', '') is null
+ or nullif(coalesce(artifact_metadata, '{}'::jsonb)->>'index_generation_id', '') =
+ nullif(coalesce(document_metadata, '{}'::jsonb)->>'index_generation_id', '');
+$$;
+
+create or replace function public.commit_document_index_generation(
+ p_document_id uuid,
+ p_index_generation_id uuid,
+ p_status text default 'indexed',
+ p_page_count integer default 0,
+ p_chunk_count integer default 0,
+ p_image_count integer default 0,
+ p_metadata jsonb default '{}'::jsonb,
+ p_pages jsonb default null,
+ p_quality jsonb default null
+)
+returns jsonb
+language plpgsql
+set search_path = public, extensions, pg_temp
+as $$
+begin
+ perform set_config('statement_timeout', '180000', true);
+
+ update public.documents
+ set
+ status = p_status,
+ page_count = p_page_count,
+ chunk_count = p_chunk_count,
+ image_count = p_image_count,
+ error_message = null,
+ metadata = coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('index_generation_id', p_index_generation_id),
+ updated_at = now()
+ where id = p_document_id;
+
+ if p_pages is not null then
+ delete from public.document_pages
+ where document_id = p_document_id;
+
+ insert into public.document_pages (document_id, page_number, text, ocr_used, metadata)
+ select
+ p_document_id,
+ page_row.page_number,
+ coalesce(page_row.text, ''),
+ coalesce(page_row.ocr_used, false),
+ coalesce(page_row.metadata, '{}'::jsonb)
+ from jsonb_to_recordset(coalesce(p_pages, '[]'::jsonb)) as page_row(
+ page_number integer,
+ text text,
+ ocr_used boolean,
+ metadata jsonb
+ )
+ where page_row.page_number is not null;
+ end if;
+
+ if p_quality is not null then
+ insert into public.document_index_quality (
+ document_id,
+ owner_id,
+ quality_score,
+ extraction_quality,
+ metrics,
+ issues,
+ updated_at
+ )
+ values (
+ p_document_id,
+ nullif(p_quality->>'owner_id', '')::uuid,
+ coalesce((p_quality->>'quality_score')::real, 0),
+ coalesce(nullif(p_quality->>'extraction_quality', ''), 'unknown'),
+ coalesce(p_quality->'metrics', '{}'::jsonb),
+ coalesce(
+ array(select jsonb_array_elements_text(coalesce(p_quality->'issues', '[]'::jsonb))),
+ '{}'::text[]
+ ),
+ now()
+ )
+ on conflict on constraint document_index_quality_pkey
+ do update set
+ owner_id = excluded.owner_id,
+ quality_score = excluded.quality_score,
+ extraction_quality = excluded.extraction_quality,
+ metrics = excluded.metrics,
+ issues = excluded.issues,
+ updated_at = excluded.updated_at;
+ end if;
+
+ delete from public.document_chunks
+ where document_id = p_document_id
+ and (index_generation_id is null or index_generation_id <> p_index_generation_id);
+
+ delete from public.document_images
+ where document_id = p_document_id
+ and (
+ nullif(metadata->>'index_generation_id', '') is null
+ or metadata->>'index_generation_id' <> p_index_generation_id::text
+ );
+
+ delete from public.document_table_facts
+ where document_id = p_document_id
+ and (
+ nullif(metadata->>'index_generation_id', '') is null
+ or metadata->>'index_generation_id' <> p_index_generation_id::text
+ );
+
+ delete from public.document_embedding_fields
+ where document_id = p_document_id
+ and (
+ nullif(metadata->>'index_generation_id', '') is null
+ or metadata->>'index_generation_id' <> p_index_generation_id::text
+ );
+
+ delete from public.document_index_units
+ where document_id = p_document_id
+ and (
+ nullif(metadata->>'index_generation_id', '') is null
+ or metadata->>'index_generation_id' <> p_index_generation_id::text
+ );
+
+ delete from public.document_memory_cards
+ where document_id = p_document_id
+ and (
+ nullif(metadata->>'index_generation_id', '') is null
+ or metadata->>'index_generation_id' <> p_index_generation_id::text
+ );
+
+ delete from public.document_sections
+ where document_id = p_document_id
+ and (
+ nullif(metadata->>'index_generation_id', '') is null
+ or metadata->>'index_generation_id' <> p_index_generation_id::text
+ );
+
+ return jsonb_build_object(
+ 'ok', true,
+ 'document_id', p_document_id,
+ 'index_generation_id', p_index_generation_id
+ );
+end;
+$$;
+
+grant execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) to service_role;
+grant execute on function public.is_committed_document_generation(uuid, jsonb) to service_role;
+grant execute on function public.is_committed_artifact_generation(jsonb, jsonb) to service_role;
+
+do $$
+declare
+ ddl text;
+ patched text;
+begin
+ select pg_get_functiondef('public.match_document_chunks(extensions.vector, integer, double precision, uuid, uuid)'::regprocedure) into ddl;
+ patched := replace(
+ ddl,
+ E' and d.status = ''indexed''\n and 1 - (c.embedding <=> query_embedding) >= min_similarity',
+ E' and d.status = ''indexed''\n and public.is_committed_document_generation(c.index_generation_id, d.metadata)\n and 1 - (c.embedding <=> query_embedding) >= min_similarity'
+ );
+ if patched = ddl then raise exception 'atomic reindex patch did not match match_document_chunks'; end if;
+ execute patched;
+
+ select pg_get_functiondef('public.match_document_chunks_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)'::regprocedure) into ddl;
+ patched := replace(
+ ddl,
+ E' and d.status = ''indexed''\n and 1 - (c.embedding <=> query_embedding) >= min_similarity',
+ E' and d.status = ''indexed''\n and public.is_committed_document_generation(c.index_generation_id, d.metadata)\n and 1 - (c.embedding <=> query_embedding) >= min_similarity'
+ );
+ patched := replace(
+ patched,
+ E' and d.status = ''indexed''\n and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)',
+ E' and d.status = ''indexed''\n and public.is_committed_document_generation(c.index_generation_id, d.metadata)\n and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)'
+ );
+ if patched = ddl then raise exception 'atomic reindex patch did not match match_document_chunks_hybrid'; end if;
+ execute patched;
+
+ select pg_get_functiondef('public.match_document_memory_cards_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)'::regprocedure) into ddl;
+ patched := replace(
+ ddl,
+ E' and d.status = ''indexed''\n and 1 - (m.embedding <=> query_embedding) >= min_similarity',
+ E' and d.status = ''indexed''\n and public.is_committed_artifact_generation(m.metadata, d.metadata)\n and 1 - (m.embedding <=> query_embedding) >= min_similarity'
+ );
+ patched := replace(
+ patched,
+ E' and d.status = ''indexed''\n and m.search_tsv @@ query.tsq',
+ E' and d.status = ''indexed''\n and public.is_committed_artifact_generation(m.metadata, d.metadata)\n and m.search_tsv @@ query.tsq'
+ );
+ if patched = ddl then raise exception 'atomic reindex patch did not match match_document_memory_cards_hybrid'; end if;
+ execute patched;
+
+ select pg_get_functiondef('public.match_document_chunks_text(text, integer, uuid[], uuid)'::regprocedure) into ddl;
+ patched := replace(
+ ddl,
+ E' and d.status = ''indexed''\n and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)',
+ E' and d.status = ''indexed''\n and public.is_committed_document_generation(c.index_generation_id, d.metadata)\n and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)'
+ );
+ if patched = ddl then raise exception 'atomic reindex patch did not match match_document_chunks_text'; end if;
+ execute patched;
+
+ select pg_get_functiondef('public.match_document_lookup_chunks_text(text, uuid[], integer, uuid)'::regprocedure) into ddl;
+ patched := replace(
+ ddl,
+ E' and d.status = ''indexed''\n and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)',
+ E' and d.status = ''indexed''\n and public.is_committed_document_generation(c.index_generation_id, d.metadata)\n and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)'
+ );
+ if patched = ddl then raise exception 'atomic reindex patch did not match match_document_lookup_chunks_text'; end if;
+ execute patched;
+
+ select pg_get_functiondef('public.match_document_table_facts_text(text, integer, uuid[], uuid)'::regprocedure) into ddl;
+ patched := replace(
+ ddl,
+ E' and d.status = ''indexed''\n and (',
+ E' and d.status = ''indexed''\n and public.is_committed_artifact_generation(f.metadata, d.metadata)\n and ('
+ );
+ if patched = ddl then raise exception 'atomic reindex patch did not match match_document_table_facts_text'; end if;
+ execute patched;
+
+ select pg_get_functiondef('public.match_document_embedding_fields_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)'::regprocedure) into ddl;
+ patched := replace(
+ ddl,
+ E' and d.status = ''indexed''\n and f.source_chunk_id is not null',
+ E' and d.status = ''indexed''\n and public.is_committed_artifact_generation(f.metadata, d.metadata)\n and f.source_chunk_id is not null'
+ );
+ if patched = ddl then raise exception 'atomic reindex patch did not match match_document_embedding_fields_hybrid'; end if;
+ execute patched;
+
+ select pg_get_functiondef('public.match_document_index_units_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)'::regprocedure) into ddl;
+ patched := replace(
+ ddl,
+ E' where d.status = ''indexed''\n and (document_filters is null or u.document_id = any(document_filters))\n and (owner_filter is null or u.owner_id = owner_filter)\n and u.source_chunk_id is not null',
+ E' where d.status = ''indexed''\n and (document_filters is null or u.document_id = any(document_filters))\n and (owner_filter is null or u.owner_id = owner_filter)\n and public.is_committed_artifact_generation(u.metadata, d.metadata)\n and u.source_chunk_id is not null'
+ );
+ if patched = ddl then raise exception 'atomic reindex patch did not match match_document_index_units_hybrid'; end if;
+ execute patched;
+end;
+$$;
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 8e19471899..f53080f3ff 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -273,8 +273,7 @@ create table if not exists public.document_chunks (
search_tsv tsvector generated always as (
to_tsvector('english', coalesce(section_heading, '') || ' ' || coalesce(retrieval_synopsis, '') || ' ' || content)
) stored,
- created_at timestamptz not null default now(),
- unique (document_id, chunk_index)
+ created_at timestamptz not null default now()
);
create table if not exists public.document_table_facts (
@@ -569,6 +568,9 @@ create index if not exists document_memory_cards_embedding_hnsw_idx
with (m = 24, ef_construction = 128);
create index if not exists document_chunks_document_idx on public.document_chunks(document_id, chunk_index);
create index if not exists document_chunks_generation_idx on public.document_chunks(document_id, index_generation_id);
+create unique index if not exists document_chunks_document_generation_chunk_idx
+ on public.document_chunks(document_id, index_generation_id, chunk_index)
+ where index_generation_id is not null;
create index if not exists document_chunks_content_hash_idx on public.document_chunks(document_id, content_hash);
create index if not exists document_chunks_section_path_gin_idx
on public.document_chunks using gin(section_path);
@@ -1068,6 +1070,141 @@ begin
end;
$$;
+create or replace function public.commit_document_index_generation(
+ p_document_id uuid,
+ p_index_generation_id uuid,
+ p_status text default 'indexed',
+ p_page_count integer default 0,
+ p_chunk_count integer default 0,
+ p_image_count integer default 0,
+ p_metadata jsonb default '{}'::jsonb,
+ p_pages jsonb default null,
+ p_quality jsonb default null
+)
+returns jsonb
+language plpgsql
+set search_path = public, extensions, pg_temp
+as $$
+begin
+ perform set_config('statement_timeout', '180000', true);
+
+ update public.documents
+ set
+ status = p_status,
+ page_count = p_page_count,
+ chunk_count = p_chunk_count,
+ image_count = p_image_count,
+ error_message = null,
+ metadata = coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('index_generation_id', p_index_generation_id),
+ updated_at = now()
+ where id = p_document_id;
+
+ if p_pages is not null then
+ delete from public.document_pages
+ where document_id = p_document_id;
+
+ insert into public.document_pages (document_id, page_number, text, ocr_used, metadata)
+ select
+ p_document_id,
+ page_row.page_number,
+ coalesce(page_row.text, ''),
+ coalesce(page_row.ocr_used, false),
+ coalesce(page_row.metadata, '{}'::jsonb)
+ from jsonb_to_recordset(coalesce(p_pages, '[]'::jsonb)) as page_row(
+ page_number integer,
+ text text,
+ ocr_used boolean,
+ metadata jsonb
+ )
+ where page_row.page_number is not null;
+ end if;
+
+ if p_quality is not null then
+ insert into public.document_index_quality (
+ document_id,
+ owner_id,
+ quality_score,
+ extraction_quality,
+ metrics,
+ issues,
+ updated_at
+ )
+ values (
+ p_document_id,
+ nullif(p_quality->>'owner_id', '')::uuid,
+ coalesce((p_quality->>'quality_score')::real, 0),
+ coalesce(nullif(p_quality->>'extraction_quality', ''), 'unknown'),
+ coalesce(p_quality->'metrics', '{}'::jsonb),
+ coalesce(
+ array(select jsonb_array_elements_text(coalesce(p_quality->'issues', '[]'::jsonb))),
+ '{}'::text[]
+ ),
+ now()
+ )
+ on conflict on constraint document_index_quality_pkey
+ do update set
+ owner_id = excluded.owner_id,
+ quality_score = excluded.quality_score,
+ extraction_quality = excluded.extraction_quality,
+ metrics = excluded.metrics,
+ issues = excluded.issues,
+ updated_at = excluded.updated_at;
+ end if;
+
+ delete from public.document_chunks
+ where document_id = p_document_id
+ and (index_generation_id is null or index_generation_id <> p_index_generation_id);
+
+ delete from public.document_images
+ where document_id = p_document_id
+ and (
+ nullif(metadata->>'index_generation_id', '') is null
+ or metadata->>'index_generation_id' <> p_index_generation_id::text
+ );
+
+ delete from public.document_table_facts
+ where document_id = p_document_id
+ and (
+ nullif(metadata->>'index_generation_id', '') is null
+ or metadata->>'index_generation_id' <> p_index_generation_id::text
+ );
+
+ delete from public.document_embedding_fields
+ where document_id = p_document_id
+ and (
+ nullif(metadata->>'index_generation_id', '') is null
+ or metadata->>'index_generation_id' <> p_index_generation_id::text
+ );
+
+ delete from public.document_index_units
+ where document_id = p_document_id
+ and (
+ nullif(metadata->>'index_generation_id', '') is null
+ or metadata->>'index_generation_id' <> p_index_generation_id::text
+ );
+
+ delete from public.document_memory_cards
+ where document_id = p_document_id
+ and (
+ nullif(metadata->>'index_generation_id', '') is null
+ or metadata->>'index_generation_id' <> p_index_generation_id::text
+ );
+
+ delete from public.document_sections
+ where document_id = p_document_id
+ and (
+ nullif(metadata->>'index_generation_id', '') is null
+ or metadata->>'index_generation_id' <> p_index_generation_id::text
+ );
+
+ return jsonb_build_object(
+ 'ok', true,
+ 'document_id', p_document_id,
+ 'index_generation_id', p_index_generation_id
+ );
+end;
+$$;
+
create or replace function public.refresh_import_batch_status(p_batch_id uuid)
returns jsonb
language plpgsql
@@ -1307,6 +1444,33 @@ as $$
limit 1;
$$;
+create or replace function public.is_committed_document_generation(
+ row_generation uuid,
+ document_metadata jsonb
+)
+returns boolean
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ select row_generation is null
+ or row_generation::text = nullif(coalesce(document_metadata, '{}'::jsonb)->>'index_generation_id', '');
+$$;
+
+create or replace function public.is_committed_artifact_generation(
+ artifact_metadata jsonb,
+ document_metadata jsonb
+)
+returns boolean
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ select nullif(coalesce(artifact_metadata, '{}'::jsonb)->>'index_generation_id', '') is null
+ or nullif(coalesce(artifact_metadata, '{}'::jsonb)->>'index_generation_id', '') =
+ nullif(coalesce(document_metadata, '{}'::jsonb)->>'index_generation_id', '');
+$$;
+
create or replace function public.match_document_chunks(
query_embedding extensions.vector(1536),
match_count integer default 8,
@@ -1356,6 +1520,7 @@ as $$
where (document_filter is null or c.document_id = document_filter)
and (owner_filter is null or d.owner_id = owner_filter)
and d.status = 'indexed'
+ and public.is_committed_document_generation(c.index_generation_id, d.metadata)
and 1 - (c.embedding <=> query_embedding) >= min_similarity
order by c.embedding <=> query_embedding
limit match_count;
@@ -1423,6 +1588,7 @@ as $$
where (document_filters is null or c.document_id = any(document_filters))
and (owner_filter is null or d.owner_id = owner_filter)
and d.status = 'indexed'
+ and public.is_committed_document_generation(c.index_generation_id, d.metadata)
and 1 - (c.embedding <=> query_embedding) >= min_similarity
order by c.embedding <=> query_embedding
limit least(greatest(match_count * 2, 48), 128)
@@ -1463,6 +1629,7 @@ as $$
where (document_filters is null or c.document_id = any(document_filters))
and (owner_filter is null or d.owner_id = owner_filter)
and d.status = 'indexed'
+ and public.is_committed_document_generation(c.index_generation_id, d.metadata)
and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)
order by (
ts_rank_cd(c.search_tsv, query.tsq) +
@@ -1614,6 +1781,7 @@ as $$
where (document_filters is null or m.document_id = any(document_filters))
and (owner_filter is null or d.owner_id = owner_filter)
and d.status = 'indexed'
+ and public.is_committed_artifact_generation(m.metadata, d.metadata)
and 1 - (m.embedding <=> query_embedding) >= min_similarity
order by m.embedding <=> query_embedding
limit greatest(match_count * 4, 64)
@@ -1633,6 +1801,7 @@ as $$
where (document_filters is null or m.document_id = any(document_filters))
and (owner_filter is null or d.owner_id = owner_filter)
and d.status = 'indexed'
+ and public.is_committed_artifact_generation(m.metadata, d.metadata)
and m.search_tsv @@ query.tsq
order by ts_rank_cd(m.search_tsv, query.tsq) desc
limit greatest(match_count * 4, 64)
@@ -2024,6 +2193,7 @@ as $$
where (document_filters is null or c.document_id = any(document_filters))
and (owner_filter is null or d.owner_id = owner_filter)
and d.status = 'indexed'
+ and public.is_committed_document_generation(c.index_generation_id, d.metadata)
and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)
order by (
ts_rank_cd(c.search_tsv, query.tsq) +
@@ -2115,6 +2285,7 @@ as $$
and c.document_id = any(document_filters)
and (owner_filter is null or d.owner_id = owner_filter)
and d.status = 'indexed'
+ and public.is_committed_document_generation(c.index_generation_id, d.metadata)
and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)
order by text_rank desc, c.chunk_index asc
limit least(greatest(match_count, 1), 80);
@@ -2254,6 +2425,7 @@ as $$
where (document_filters is null or f.document_id = any(document_filters))
and (owner_filter is null or f.owner_id = owner_filter)
and d.status = 'indexed'
+ and public.is_committed_artifact_generation(f.metadata, d.metadata)
and (
f.search_tsv @@ query.tsq
or f.normalized_terms && query.terms
@@ -2316,6 +2488,7 @@ as $$
where (document_filters is null or f.document_id = any(document_filters))
and (owner_filter is null or f.owner_id = owner_filter)
and d.status = 'indexed'
+ and public.is_committed_artifact_generation(f.metadata, d.metadata)
and f.source_chunk_id is not null
and (
1 - (f.embedding <=> query_embedding) >= min_similarity
@@ -2798,6 +2971,40 @@ $$;
revoke execute on function public.complete_strict_enrichment_job(uuid, uuid, text, text, text) from public, anon, authenticated;
grant execute on function public.complete_strict_enrichment_job(uuid, uuid, text, text, text) to service_role;
+create or replace function public.invoke_indexing_v3_agent(p_limit integer default 1)
+returns bigint
+language plpgsql
+security definer
+set search_path = public, extensions, vault, pg_temp
+as $$
+declare
+ v_request_id bigint;
+ v_secret text;
+begin
+ select decrypted_secret
+ into v_secret
+ from vault.decrypted_secrets
+ where name = 'indexing_v3_agent_secret'
+ limit 1;
+
+ if nullif(v_secret, '') is null then
+ raise exception 'indexing_v3_agent_secret is missing from Supabase Vault';
+ end if;
+
+ select net.http_post(
+ url := 'https://sjrfecxgysukkwxsowpy.supabase.co/functions/v1/indexing-v3-agent?limit=' || greatest(1, least(coalesce(p_limit, 1), 10))::text,
+ headers := jsonb_build_object(
+ 'Content-Type', 'application/json',
+ 'x-indexing-agent-secret', v_secret
+ ),
+ body := jsonb_build_object('source', 'pg_cron', 'worker', 'v3-indexing-worker', 'ts', now()),
+ timeout_milliseconds := 60000
+ ) into v_request_id;
+
+ return v_request_id;
+end;
+$$;
+
alter default privileges for role postgres in schema public
revoke all privileges on tables from anon, authenticated;
alter default privileges for role postgres in schema public
@@ -2847,6 +3054,8 @@ grant usage, select on all sequences in schema public to service_role;
grant execute on all functions in schema public to service_role;
revoke execute on function public.claim_indexing_v3_agent_jobs(text, integer, integer) from public, anon, authenticated;
grant execute on function public.claim_indexing_v3_agent_jobs(text, integer, integer) to service_role;
+revoke execute on function public.invoke_indexing_v3_agent(integer) from public, anon, authenticated;
+grant execute on function public.invoke_indexing_v3_agent(integer) to service_role;
grant select on table
public.import_batches,
@@ -3091,6 +3300,7 @@ as $$
where d.status = 'indexed'
and (document_filters is null or u.document_id = any(document_filters))
and (owner_filter is null or u.owner_id = owner_filter)
+ and public.is_committed_artifact_generation(u.metadata, d.metadata)
and u.source_chunk_id is not null
and (u.search_tsv @@ query.tsq or u.normalized_terms && query.terms)
order by text_rank desc, similarity desc
@@ -3154,6 +3364,9 @@ alter table public.document_index_units enable row level security;
grant select, insert, update, delete on table public.document_index_units to service_role;
grant select on table public.document_index_units to authenticated;
grant execute on function public.match_document_index_units_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid) to service_role;
+grant execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) to service_role;
+grant execute on function public.is_committed_document_generation(uuid, jsonb) to service_role;
+grant execute on function public.is_committed_artifact_generation(jsonb, jsonb) to service_role;
create policy "document index units owner read" on public.document_index_units
for select to authenticated using (
diff --git a/tests/deep-memory.test.ts b/tests/deep-memory.test.ts
index 9f5d2fca20..d769f788b7 100644
--- a/tests/deep-memory.test.ts
+++ b/tests/deep-memory.test.ts
@@ -324,4 +324,3 @@ describe("deep RAG memory indexing", () => {
);
});
});
-
diff --git a/tests/display-text.test.ts b/tests/display-text.test.ts
new file mode 100644
index 0000000000..6bb387b282
--- /dev/null
+++ b/tests/display-text.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from "vitest";
+import { sanitizeAnswerDisplayText } from "../src/components/clinical-dashboard/display-text";
+
+describe("clinical dashboard display text", () => {
+ it("polishes cached generated answer prose before rendering", () => {
+ const noisy =
+ "Lithium Carbonate 250 mg Tablet – Lithicarb®. Imprest location: Formulary One DOSAGE & DOSAGE ADJUSTMENTS Therapy with lithium should always begin with conventional tablets (Lithium Carbonate 250 mg) to stabilise the do. Lithium MONITORING Baseline Tests1.";
+
+ expect(sanitizeAnswerDisplayText(noisy)).toBe(
+ "Therapy with lithium should always begin with conventional tablets (lithium carbonate 250 mg).",
+ );
+ });
+});
diff --git a/tests/document-enrichment.test.ts b/tests/document-enrichment.test.ts
index 0ff5624ed5..13eb9b14a8 100644
--- a/tests/document-enrichment.test.ts
+++ b/tests/document-enrichment.test.ts
@@ -64,6 +64,11 @@ function createSupabaseMock() {
return this;
}
+ is(column: string, value: unknown) {
+ this.call.filters.push({ column, value });
+ return this;
+ }
+
single() {
return this.resolve();
}
diff --git a/tests/document-index-units.test.ts b/tests/document-index-units.test.ts
index b39dc78b52..0925e7fd7d 100644
--- a/tests/document-index-units.test.ts
+++ b/tests/document-index-units.test.ts
@@ -112,7 +112,13 @@ describe("document index units", () => {
flowchart_edges: [{ from: "assess", to: "escalate", label: "red zone" }],
risk_matrix_axes: ["likelihood", "consequence"],
risk_matrix_cells: [
- { row: "High likelihood", column: "Severe consequence", risk: "Red", action: "Escalate", confidence: 0.9 },
+ {
+ row: "High likelihood",
+ column: "Severe consequence",
+ risk: "Red",
+ action: "Escalate",
+ confidence: 0.9,
+ },
],
chart_axes: [],
chart_findings: [],
@@ -136,7 +142,9 @@ describe("document index units", () => {
"risk_matrix_cell",
]),
);
- expect(units.filter((unit) => unit.unit_type.startsWith("visual")).every((unit) => unit.source_image_id === "image-1")).toBe(true);
+ expect(
+ units.filter((unit) => unit.unit_type.startsWith("visual")).every((unit) => unit.source_image_id === "image-1"),
+ ).toBe(true);
expect(units.find((unit) => unit.unit_type === "risk_matrix_cell")?.metadata.visual_intelligence_version).toBe(
"visual-intelligence-v1",
);
diff --git a/tests/document-organization.test.ts b/tests/document-organization.test.ts
index ecd94dcfb9..08fea0a7f0 100644
--- a/tests/document-organization.test.ts
+++ b/tests/document-organization.test.ts
@@ -97,4 +97,64 @@ describe("document organization classifier", () => {
}),
).toBe("Management Of(CAR-T Cell) Therapy Recipients");
});
+
+ it("classifies new document types accurately based on title keywords", () => {
+ expect(
+ classifyDocumentOrganization({
+ title: "6C Orientation Manual",
+ file_name: "6C Orientation Manual (RPBG).pdf",
+ contentText: "Welcome to 6C.",
+ }).profile.document_type.label,
+ ).toBe("manual");
+
+ expect(
+ classifyDocumentOrganization({
+ title: "Abnormal Involuntary Movement Scale (AIMS)",
+ file_name: "AIMS (FSH).pdf",
+ contentText: "Movement scale assessment.",
+ }).profile.document_type.label,
+ ).toBe("assessment_tool");
+
+ expect(
+ classifyDocumentOrganization({
+ title: "Lithium Prescribing Aid and Calculator",
+ file_name: "lithium_prescribing_aid.pdf",
+ contentText: "Dosing and monitoring aid.",
+ }).profile.document_type.label,
+ ).toBe("prescribing_aid");
+
+ expect(
+ classifyDocumentOrganization({
+ title: "Clozapine Factsheet for Patients",
+ file_name: "clozapine_patient_factsheet.pdf",
+ contentText: "Patient information sheet.",
+ }).profile.document_type.label,
+ ).toBe("factsheet");
+
+ expect(
+ classifyDocumentOrganization({
+ title: "Acute Severe Behavioral Disturbance (ASBD) Management Algorithm",
+ file_name: "ASBD Algorithm (PHC).pdf",
+ contentText: "Clinical decision flowchart.",
+ }).profile.document_type.label,
+ ).toBe("algorithm");
+
+ expect(
+ classifyDocumentOrganization({
+ title: "Acute Surgical Unit SOP",
+ file_name: "ASU SOP (RPBG).pdf",
+ contentText: "Standard operating procedure.",
+ }).profile.document_type.label,
+ ).toBe("procedure");
+ });
+
+ it("flags low-confidence document type classifications as needs_review", () => {
+ const classification = classifyDocumentOrganization({
+ title: "Random Document",
+ file_name: "random_doc.pdf",
+ contentText: "Does not contain any pattern keywords.",
+ });
+ expect(classification.profile.document_type.label).toBe("unknown");
+ expect(classification.profile.review_status).toBe("needs_review");
+ });
});
diff --git a/tests/embedding-dimensions.test.ts b/tests/embedding-dimensions.test.ts
index 5918bb4327..f4526e08af 100644
--- a/tests/embedding-dimensions.test.ts
+++ b/tests/embedding-dimensions.test.ts
@@ -11,7 +11,8 @@ describe("strict embedding dimension guard", () => {
it("rejects non-arrays, wrong dimensions, and non-finite values", () => {
expect(() => assertEmbeddingDim("not-a-vector", "test_vector")).toThrow(/must be an array/);
expect(() => assertEmbeddingDim([0.1, 0.2], "test_vector")).toThrow(/2 dimensions; expected 1536/);
- expect(() => assertEmbeddingDim([...Array.from({ length: EXPECTED_EMBED_DIM - 1 }, () => 0), Infinity], "test_vector"))
- .toThrow(/non-finite value at index 1535/);
+ expect(() =>
+ assertEmbeddingDim([...Array.from({ length: EXPECTED_EMBED_DIM - 1 }, () => 0), Infinity], "test_vector"),
+ ).toThrow(/non-finite value at index 1535/);
});
});
diff --git a/tests/indexing-v3-agent.test.ts b/tests/indexing-v3-agent.test.ts
index 970483c9ec..09a6928478 100644
--- a/tests/indexing-v3-agent.test.ts
+++ b/tests/indexing-v3-agent.test.ts
@@ -1,3 +1,4 @@
+import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
completionGateFromRow,
@@ -20,6 +21,7 @@ const completeGateRow: CompletionGateRow = {
missing: [],
gate_passed: true,
};
+const edgeSource = readFileSync(new URL("../supabase/functions/indexing-v3-agent/index.ts", import.meta.url), "utf8");
function gateRow(overrides: Partial = {}): CompletionGateRow {
return { ...completeGateRow, ...overrides };
@@ -155,12 +157,6 @@ describe("indexing-v3-agent behavior", () => {
});
it("documents that local worker visual units satisfy visual artifact capture", () => {
- const edgeSource = String(
- await import("node:fs/promises").then((fs) =>
- fs.readFile(new URL("../supabase/functions/indexing-v3-agent/index.ts", import.meta.url), "utf8"),
- ),
- );
-
expect(edgeSource).toContain("metadata->>'generated_by' = 'local-worker'");
expect(edgeSource).toContain("metadata->>'source' = 'visual_intelligence'");
});
diff --git a/tests/ingestion-quality-route.test.ts b/tests/ingestion-quality-route.test.ts
index c862edab6e..2126741f9c 100644
--- a/tests/ingestion-quality-route.test.ts
+++ b/tests/ingestion-quality-route.test.ts
@@ -105,7 +105,14 @@ describe("/api/ingestion/quality", () => {
expect(response.status).toBe(200);
expect(payload.items.map((item: { type: string }) => item.type)).toEqual(
- expect.arrayContaining(["failed_job", "failed_ocr", "image_only_pages", "missing_tables", "low_extraction_confidence", "manual_review"]),
+ expect.arrayContaining([
+ "failed_job",
+ "failed_ocr",
+ "image_only_pages",
+ "missing_tables",
+ "low_extraction_confidence",
+ "manual_review",
+ ]),
);
expect(payload.items[0]).toMatchObject({
type: "failed_job",
diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts
index 07e7efdb18..a728ccc475 100644
--- a/tests/private-access-routes.test.ts
+++ b/tests/private-access-routes.test.ts
@@ -85,6 +85,11 @@ class QueryBuilder implements PromiseLike {
return this;
}
+ is(column: string, value: unknown) {
+ this.call.filters.push({ column, value });
+ return this;
+ }
+
not(column: string, _operator: string, value: unknown) {
this.call.filters.push({ column, value });
return this;
@@ -392,6 +397,22 @@ describe("private document API access", () => {
expect(await payload(response)).toEqual({ error: "Request failed." });
});
+ it("does not leak demo documents from real-mode listing failures", async () => {
+ const client = createSupabaseMock(() => {
+ throw new Error("Missing server environment variables: SUPABASE_SERVICE_ROLE_KEY. See .env.example.");
+ });
+ mockRuntime(client);
+ const { GET } = await import("../src/app/api/documents/route");
+
+ const response = await GET(authenticatedRequest("/api/documents"));
+ const body = await payload(response);
+
+ expect(response.status).toBe(500);
+ expect(body).toEqual({ error: "Request failed." });
+ expect(body.documents).toBeUndefined();
+ expect(body.demoMode).toBeUndefined();
+ });
+
it("allows document signed URLs only for owned documents", async () => {
const client = createSupabaseMock((call) => {
if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) {
@@ -861,6 +882,97 @@ describe("private document API access", () => {
);
});
+ it("filters enrichment-only reindex rows to the committed document generation", async () => {
+ const document = {
+ id: documentId,
+ owner_id: userId,
+ title: "Atomic Protocol",
+ file_name: "atomic.pdf",
+ source_path: null,
+ import_batch_id: null,
+ metadata: { index_generation_id: "11111111-1111-4111-8111-111111111111" },
+ };
+ const committedChunk = {
+ id: "chunk-committed",
+ document_id: documentId,
+ page_number: 1,
+ chunk_index: 0,
+ section_heading: "Committed",
+ content: "Committed generation content.",
+ image_ids: [],
+ metadata: { index_generation_id: "11111111-1111-4111-8111-111111111111" },
+ };
+ const uncommittedChunk = {
+ id: "chunk-uncommitted",
+ document_id: documentId,
+ page_number: 1,
+ chunk_index: 1,
+ section_heading: "Uncommitted",
+ content: "Replacement generation content.",
+ image_ids: [],
+ metadata: { index_generation_id: "22222222-2222-4222-8222-222222222222" },
+ };
+ const committedImage = {
+ id: imageId,
+ page_number: 1,
+ caption: "Committed image.",
+ image_type: "clinical_table",
+ labels: [],
+ clinical_relevance_score: 0.8,
+ metadata: { index_generation_id: "11111111-1111-4111-8111-111111111111" },
+ };
+ const uncommittedImage = {
+ id: "33333333-3333-4333-8333-333333333333",
+ page_number: 1,
+ caption: "Uncommitted image.",
+ image_type: "clinical_table",
+ labels: [],
+ clinical_relevance_score: 0.9,
+ metadata: { index_generation_id: "22222222-2222-4222-8222-222222222222" },
+ };
+ const client = createSupabaseMock((call) => {
+ if (call.table === "documents" && call.operation === "select") return ok(document);
+ if (call.table === "document_chunks") return ok([committedChunk, uncommittedChunk]);
+ if (call.table === "document_images") return ok([uncommittedImage, committedImage]);
+ return ok([]);
+ });
+ const upsertDocumentEnrichment = vi.fn(async () => ({
+ summary: { id: "summary-1", document_id: documentId, summary: "Source-backed summary." },
+ labels: [],
+ }));
+ const upsertDocumentDeepMemory = vi.fn(async () => ({
+ sections: [],
+ memoryCards: [],
+ indexUnits: [],
+ }));
+ mockRuntime(client);
+ vi.doMock("@/lib/document-enrichment", () => ({ upsertDocumentEnrichment }));
+ vi.doMock("@/lib/deep-memory", () => ({ upsertDocumentDeepMemory }));
+ const { POST } = await import("../src/app/api/documents/[id]/reindex/route");
+
+ const response = await POST(
+ authenticatedRequest(`/api/documents/${documentId}/reindex`, {
+ method: "POST",
+ body: JSON.stringify({ mode: "enrichment" }),
+ }),
+ { params: Promise.resolve({ id: documentId }) },
+ );
+
+ expect(response.status).toBe(200);
+ expect(upsertDocumentEnrichment).toHaveBeenCalledWith(
+ expect.objectContaining({
+ chunks: [committedChunk],
+ images: [committedImage],
+ }),
+ );
+ expect(upsertDocumentDeepMemory).toHaveBeenCalledWith(
+ expect.objectContaining({
+ chunks: [committedChunk],
+ images: [committedImage],
+ }),
+ );
+ });
+
it("paginates enrichment-only reindex chunks and images for deep memory rebuilds", async () => {
const document = {
id: documentId,
@@ -1019,6 +1131,62 @@ describe("private document API access", () => {
expect(client.calls.some((call) => call.table === "documents" && call.operation === "update")).toBe(false);
});
+ it("blocks enrichment-only reindex when the selected document already has active indexing work", async () => {
+ const document = {
+ id: documentId,
+ owner_id: userId,
+ title: "Active Protocol",
+ file_name: "active.pdf",
+ source_path: null,
+ import_batch_id: null,
+ metadata: { index_generation_id: "11111111-1111-4111-8111-111111111111" },
+ };
+ const client = createSupabaseMock((call) => {
+ if (call.table === "documents" && call.operation === "select") return ok(document);
+ if (call.table === "import_batches") return ok([]);
+ if (call.table === "ingestion_jobs" && call.operation === "select") {
+ return ok([
+ {
+ id: "active-job-1",
+ document_id: documentId,
+ status: "pending",
+ stage: "queued",
+ locked_at: null,
+ updated_at: new Date().toISOString(),
+ error_message: null,
+ attempt_count: 0,
+ max_attempts: 3,
+ },
+ ]);
+ }
+ return ok([]);
+ });
+ const upsertDocumentEnrichment = vi.fn();
+ mockRuntime(client);
+ vi.doMock("@/lib/document-enrichment", () => ({ upsertDocumentEnrichment }));
+ const { POST } = await import("../src/app/api/documents/[id]/reindex/route");
+
+ const response = await POST(
+ authenticatedRequest(`/api/documents/${documentId}/reindex`, {
+ method: "POST",
+ body: JSON.stringify({ mode: "enrichment" }),
+ }),
+ { params: Promise.resolve({ id: documentId }) },
+ );
+ const body = await payload(response);
+
+ expect(response.status).toBe(409);
+ expect(body).toMatchObject({
+ safety: {
+ safeToRun: false,
+ reason: "active_jobs",
+ activeJobCount: 1,
+ },
+ });
+ expect(upsertDocumentEnrichment).not.toHaveBeenCalled();
+ expect(client.calls.some((call) => call.table === "document_chunks")).toBe(false);
+ });
+
it("pauses full reindex when Supabase health is unavailable before queue mutation", async () => {
const document = {
id: documentId,
diff --git a/tests/private-client-auth.test.ts b/tests/private-client-auth.test.ts
index e46d527291..b23444e466 100644
--- a/tests/private-client-auth.test.ts
+++ b/tests/private-client-auth.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { authorizationHeadersForAccessToken } from "../src/lib/supabase/client";
+import { authorizationHeadersForAccessToken, isUsableBrowserSupabaseKey } from "../src/lib/supabase/client";
describe("browser auth helpers", () => {
it("adds a bearer token header for authenticated private API calls", () => {
@@ -12,4 +12,17 @@ describe("browser auth helpers", () => {
expect(authorizationHeadersForAccessToken(null)).toEqual({});
expect(authorizationHeadersForAccessToken(undefined)).toEqual({});
});
+
+ it("treats missing or placeholder browser Supabase keys as unconfigured", () => {
+ expect(isUsableBrowserSupabaseKey(undefined)).toBe(false);
+ expect(isUsableBrowserSupabaseKey("")).toBe(false);
+ expect(isUsableBrowserSupabaseKey("your-publishable-or-anon-key")).toBe(false);
+ expect(isUsableBrowserSupabaseKey("placeholder-ci-anon-key")).toBe(false);
+ expect(isUsableBrowserSupabaseKey("replace-with-real-publishable-key")).toBe(false);
+ });
+
+ it("allows real-looking Supabase browser keys", () => {
+ expect(isUsableBrowserSupabaseKey("sb_publishable_1234567890abcdef")).toBe(true);
+ expect(isUsableBrowserSupabaseKey("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.signature")).toBe(true);
+ });
});
diff --git a/tests/rag-answer-fallback.test.ts b/tests/rag-answer-fallback.test.ts
index a72cc450e3..6ed3270097 100644
--- a/tests/rag-answer-fallback.test.ts
+++ b/tests/rag-answer-fallback.test.ts
@@ -169,11 +169,10 @@ describe("RAG structured-output fallback", () => {
expect(generateStructuredTextResult).not.toHaveBeenCalled();
expect(answer.routingMode).toBe("extractive");
- expect(answer.answer.replace(/\*\*/g, "")).toContain("Clozapine Monitoring Form");
+ expect(answer.answer.replace(/\*\*/g, "")).toMatch(/clozapine Monitoring Form/i);
expect(answer.answer).not.toContain("- Medication point");
expect(answer.answer).not.toMatch(/Medication point:.*Medication point:/);
- expect(answer.answerSections?.[0]?.heading).toBe("Direct source-backed answer");
- expect(answer.answerSections?.[0]?.body).not.toContain("- Medication point");
+ expect(answer.answerSections ?? []).toEqual([]);
});
it("retries template-like fast answers with the strong model before returning", async () => {
@@ -288,9 +287,12 @@ describe("RAG structured-output fallback", () => {
expect(answer.routingMode).toBe("extractive");
expect(answer.routingReason).toContain("high_confidence_extractive_retrieval");
expect(answer.openAIRequestIds ?? []).toEqual([]);
- expect(answer.answer.replace(/\*\*/g, "")).toContain("Clozapine Monitoring Form");
+ expect(answer.grounded).toBe(false);
+ expect(answer.confidence).toBe("low");
+ expect(answer.responseMode).toBe("evidence_gap");
+ expect(answer.answer).toMatch(/could not find enough clean, directly relevant source text/i);
expect(answer.answer).not.toMatch(/retrieved source|source-backed|based on the provided excerpts/i);
- expect(answer.answerSections?.[0]?.body).not.toMatch(/retrieved source|source-backed/i);
+ expect(answer.answerSections ?? []).toEqual([]);
});
it("retries over-expanded fast answers for simple direct questions", async () => {
@@ -419,7 +421,7 @@ describe("RAG structured-output fallback", () => {
expect(answer.routingReason).toContain("high_confidence_extractive_retrieval");
expect(answer.openAIRequestIds ?? []).toEqual([]);
expect(answer.answer.replace(/\*\*/g, "")).toContain("Bulimia nervosa is an eating disorder");
- expect(answer.answerSections?.[0]?.heading).toBe("Direct source-backed answer");
+ expect(answer.answerSections ?? []).toEqual([]);
});
it("records fast-template and strong-quality retry telemetry", async () => {
@@ -498,8 +500,7 @@ describe("RAG structured-output fallback", () => {
})
.mockResolvedValueOnce({
text: JSON.stringify({
- answer:
- "Source-backed summaries mention management steps and review intervals.",
+ answer: "Source-backed summaries mention management steps and review intervals.",
grounded: true,
confidence: "high",
answerSections: [],
@@ -577,10 +578,7 @@ describe("RAG structured-output fallback", () => {
expect(generateStructuredTextResult).toHaveBeenCalledTimes(3);
expect(answer.routingMode).toBe("strong");
expect(answer.latencyTimings?.answer_retry_count).toBe(2);
- expect(answer.latencyTimings?.answer_retry_reasons).toEqual([
- "fast_template_retry_strong",
- "strong_quality_retry",
- ]);
+ expect(answer.latencyTimings?.answer_retry_reasons).toEqual(["fast_template_retry_strong", "strong_quality_retry"]);
expect(answer.routingReason).toContain("fast_template_retry_strong");
expect(answer.routingReason).toContain("strong_quality_retry");
expect(answer.openAIRequestIds).toEqual(["req_fast_template", "req_strong_template", "req_strong_quality"]);
@@ -588,10 +586,7 @@ describe("RAG structured-output fallback", () => {
const insertCalls = insert.mock.calls as unknown as Array<[{ metadata?: Record }]>;
const loggedMetadata = insertCalls[0]?.[0]?.metadata ?? {};
expect(loggedMetadata.answer_retry_count).toBe(2);
- expect(loggedMetadata.answer_retry_reasons).toEqual([
- "fast_template_retry_strong",
- "strong_quality_retry",
- ]);
+ expect(loggedMetadata.answer_retry_reasons).toEqual(["fast_template_retry_strong", "strong_quality_retry"]);
});
it("filters table-caption metadata from extractive answer points", async () => {
@@ -759,10 +754,7 @@ describe("RAG structured-output fallback", () => {
expect(answer.openAIRequestIds).toEqual(["req_truncated", "req_truncated", "req_truncated"]);
expect(answer.openAIUsage).toMatchObject({ output_tokens: 1950 });
expect(answer.latencyTimings?.answer_retry_count).toBe(2);
- expect(answer.latencyTimings?.answer_retry_reasons).toEqual([
- "fast_unusable_retry_strong",
- "strong_quality_retry",
- ]);
+ expect(answer.latencyTimings?.answer_retry_reasons).toEqual(["fast_unusable_retry_strong", "strong_quality_retry"]);
expect(answer.citations[0]?.source_metadata?.document_status).toBe("current");
});
diff --git a/tests/rag-answer-text.test.ts b/tests/rag-answer-text.test.ts
index 7b89be3ccb..f26f33c28f 100644
--- a/tests/rag-answer-text.test.ts
+++ b/tests/rag-answer-text.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
+ hasClinicalAnswerQualityIssue,
looksLikeJsonArtifact,
+ polishClinicalAnswerProse,
sanitizeAnswerText,
sanitizeStructuredText,
splitBalancedWords,
@@ -37,4 +39,67 @@ describe("RAG answer text helpers", () => {
);
expect(sanitizeAnswerText("OK")).toBe("");
});
+
+ it("removes product catalogue, all-caps headings, and citation suffixes from answer prose", () => {
+ const noisy =
+ "Lithium Carbonate 250 mg Tablet – Lithicarb®. Lithium Carbonate 450 mg Modified Release Tablet – Quilonum SR® Imprest location: Formulary One DOSAGE & DOSAGE ADJUSTMENTS Therapy with lithium should always begin with conventional tablets (Lithium Carbonate 250 mg) to stabilise the do. Lithium MONITORING Baseline Tests1.";
+
+ const cleaned = sanitizeAnswerText(noisy);
+
+ expect(cleaned).toBe(
+ "Therapy with lithium should always begin with conventional tablets (lithium carbonate 250 mg).",
+ );
+ expect(cleaned).not.toContain("Lithicarb");
+ expect(cleaned).not.toContain("Quilonum");
+ expect(cleaned).not.toContain("DOSAGE");
+ expect(cleaned).not.toContain("MONITORING");
+ expect(cleaned).not.toContain("Tests1");
+ });
+
+ it("removes markdown-heavy catalogue fragments before preserving useful clinical prose", () => {
+ const noisy =
+ "Dose evidence: **Lithium** Carbonate **250 mg** Tablet - Lithicarb®. Dose evidence: **Lithium** Carbonate **450 mg** Modified Release Tablet - Quilonum SR® Imprest location: Formulary One DOSAGE & DOSAGE ADJUSTMENTS Therapy with **lithium** should always begin with conventional tablets (**lithium** carbonate **250 mg**) to stabilise the do. Dose evidence: **Lithium** MONITORING Baseline Tests1.";
+
+ const cleaned = sanitizeAnswerText(noisy);
+
+ expect(cleaned).toBe(
+ "Therapy with lithium should always begin with conventional tablets (lithium carbonate 250 mg).",
+ );
+ expect(cleaned).not.toMatch(/Lithicarb|Quilonum|Imprest|DOSAGE|Dose evidence|Tests1/i);
+ });
+
+ it("flags source-inventory wording and truncated clinical fragments as answer quality issues", () => {
+ expect(
+ hasClinicalAnswerQualityIssue(
+ "The indexed source passages matched the question, but no concise source sentence could be extracted.",
+ ),
+ ).toBe(true);
+ expect(hasClinicalAnswerQualityIssue("Liver functi should be checked before treatment.")).toBe(true);
+ expect(hasClinicalAnswerQualityIssue("Monitor for respiratio before discharge.")).toBe(true);
+ });
+
+ it("polishes cached answer display text without requiring regeneration", () => {
+ expect(
+ polishClinicalAnswerProse("Serum lithium concentrations should be monitored once every three months.1"),
+ ).toBe("Serum lithium concentrations should be monitored once every three months.");
+ });
+
+ it("removes answer footnote markers without damaging clinical numbers and scales", () => {
+ expect(sanitizeAnswerText("Monitor FBC [1] and ANC (2).")).toBe("Monitor FBC and ANC.");
+ expect(sanitizeAnswerText("Check ANC1 and FBC2 before clozapine.")).toBe("Check ANC and FBC before clozapine.");
+ expect(sanitizeAnswerText("Vitamin B12 should be checked.")).toBe("Vitamin B12 should be checked.");
+ expect(sanitizeAnswerText("Use PHQ-9 score, not HAM-D17, for this check.")).toBe(
+ "Use PHQ-9 score, not HAM-D17, for this check.",
+ );
+ });
+
+ it("flags citation-marker residue as an answer quality issue", () => {
+ expect(hasClinicalAnswerQualityIssue("Monitor FBC [1] and ANC (2).")).toBe(true);
+ expect(hasClinicalAnswerQualityIssue("Check ANC1 and FBC2 before clozapine.")).toBe(true);
+ });
+
+ it("treats source form codes as quality issues while preserving clinical scales", () => {
+ expect(hasClinicalAnswerQualityIssue("Complete the Consent to Clozapine Treatment Form EMR0270.")).toBe(true);
+ expect(hasClinicalAnswerQualityIssue("Use PHQ-9 score, not HAM-D17, for this check.")).toBe(false);
+ });
});
diff --git a/tests/rag-cache-invalidation.test.ts b/tests/rag-cache-invalidation.test.ts
index 613b063356..3bcd4a9b74 100644
--- a/tests/rag-cache-invalidation.test.ts
+++ b/tests/rag-cache-invalidation.test.ts
@@ -55,7 +55,7 @@ describe("RAG cache invalidation", () => {
invalidateRagCachesForDocumentMutation(ownerId);
- await vi.waitFor(() => expect(calls.length).toBe(2));
+ await vi.waitFor(() => expect(calls.length).toBe(2), { timeout: 10000 });
expect(calls[0]).toContainEqual({ method: "eq", column: "owner_id", value: ownerId });
expect(calls.flat()).not.toContainEqual({ method: "eq", column: "owner_id", value: "anonymous" });
diff --git a/tests/rag-trust.test.ts b/tests/rag-trust.test.ts
index cf0450e034..bea6466fd8 100644
--- a/tests/rag-trust.test.ts
+++ b/tests/rag-trust.test.ts
@@ -223,6 +223,31 @@ describe("RAG trust validation", () => {
expect(sections[0]?.citation_chunk_ids).toEqual(["chunk-1"]);
});
+ it("strips prose footnotes and replaces source-catalogue section headings", () => {
+ const answer = parseAnswerJson(
+ JSON.stringify({
+ answer: "Monitor FBC [1] and ANC (2) before clozapine.",
+ grounded: true,
+ confidence: "high",
+ citations: [{ chunk_id: "chunk-1" }],
+ answerSections: [
+ {
+ heading: "Lithium Carbonate 250 mg Tablet - Lithicarb®",
+ body: "Check ANC1 and FBC2 before clozapine.",
+ citation_chunk_ids: ["chunk-1"],
+ },
+ ],
+ }),
+ [source()],
+ "clozapine monitoring",
+ );
+
+ expect(answer.answer.replace(/\*\*/g, "")).toBe("Monitor FBC and ANC before clozapine.");
+ expect(answer.answer).not.toMatch(/\[\d+\]|\(\d+\)|ANC1|FBC2/);
+ expect(answer.answerSections?.[0]?.heading).toBe("Monitoring");
+ expect(answer.answerSections?.[0]?.body.replace(/\*\*/g, "")).toBe("Check ANC and FBC before clozapine.");
+ });
+
it("includes exact citation chunk IDs in the model source block", () => {
const block = buildRagSourceBlock([source()]);
diff --git a/tests/reindex-pipeline.test.ts b/tests/reindex-pipeline.test.ts
index b76d2791ca..2d63e22727 100644
--- a/tests/reindex-pipeline.test.ts
+++ b/tests/reindex-pipeline.test.ts
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
-import { hasIncompleteDocumentsWithoutOpenJobs, isReindexQueueClear } from "../src/lib/reindex-pipeline";
+import {
+ committedIndexGeneration,
+ hasIncompleteDocumentsWithoutOpenJobs,
+ isAtomicReindexCandidate,
+ isCommittedGenerationMetadata,
+ isReindexQueueClear,
+} from "../src/lib/reindex-pipeline";
describe("reindex pipeline queue state", () => {
it("does not declare the queue clear while documents are still processing", () => {
@@ -42,4 +48,30 @@ describe("reindex pipeline queue state", () => {
}),
).toBe(true);
});
+
+ it("treats indexed documents as atomic reindex candidates", () => {
+ expect(isAtomicReindexCandidate({ status: "indexed", metadata: { index_generation_id: "old-generation" } })).toBe(
+ true,
+ );
+ expect(isAtomicReindexCandidate({ status: "queued", metadata: { index_generation_id: "old-generation" } })).toBe(
+ false,
+ );
+ });
+
+ it("compares generated artifacts against the committed document generation", () => {
+ expect(committedIndexGeneration({ index_generation_id: "generation-a" })).toBe("generation-a");
+ expect(isCommittedGenerationMetadata({ rowMetadata: {}, committedGeneration: "generation-a" })).toBe(true);
+ expect(
+ isCommittedGenerationMetadata({
+ rowMetadata: { index_generation_id: "generation-b" },
+ committedGeneration: "generation-a",
+ }),
+ ).toBe(false);
+ expect(
+ isCommittedGenerationMetadata({
+ rowMetadata: { index_generation_id: "generation-a" },
+ committedGeneration: "generation-a",
+ }),
+ ).toBe(true);
+ });
});
diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts
index 40364c070f..5cec93a432 100644
--- a/tests/retrieval-query-variants.test.ts
+++ b/tests/retrieval-query-variants.test.ts
@@ -220,7 +220,14 @@ describe("retrieval query variants", () => {
file_name: "active-community-pt-ed.pdf",
content: "Active community patients in ED require liaison with the community team.",
similarity: 0.72,
- match_explanation: { titleHit: true, labelHit: false, sectionHit: false, contentHit: true, tableHit: false, reasons: ["title"] },
+ match_explanation: {
+ titleHit: true,
+ labelHit: false,
+ sectionHit: false,
+ contentHit: true,
+ tableHit: false,
+ reasons: ["title"],
+ },
}),
],
"document_lookup",
@@ -306,7 +313,9 @@ describe("retrieval query variants", () => {
title: "Patient Property",
content: "Patient property restricted items table.",
similarity: 0.8,
- table_facts: [tableFact({ source_image_id: null, table_title: "Patient Property", row_label: "Restricted items" })],
+ table_facts: [
+ tableFact({ source_image_id: null, table_title: "Patient Property", row_label: "Restricted items" }),
+ ],
}),
],
"table_threshold",
diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts
index 469b8ed9e9..bd3f4d3066 100644
--- a/tests/supabase-schema.test.ts
+++ b/tests/supabase-schema.test.ts
@@ -34,6 +34,10 @@ const phase7RetrievalPerformanceMigration = readFileSync(
new URL("../supabase/migrations/20260626020000_phase7_retrieval_rpc_performance.sql", import.meta.url),
"utf8",
).replace(/\s+/g, " ");
+const atomicReindexMigration = readFileSync(
+ new URL("../supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql", import.meta.url),
+ "utf8",
+).replace(/\s+/g, " ");
function extractTextChunkFunction(sql: string) {
const start = sql.indexOf("function public.match_document_chunks_text");
@@ -117,6 +121,28 @@ describe("Supabase schema Data API grants", () => {
expect(schema).toContain("delete from public.document_sections where document_id = p_document_id;");
});
+ it("keeps replacement reindex generations invisible until commit", () => {
+ for (const sql of [schema, atomicReindexMigration]) {
+ expect(sql).toContain("create or replace function public.commit_document_index_generation");
+ expect(sql).toContain("document_chunks_document_generation_chunk_idx");
+ expect(sql).toContain("create or replace function public.is_committed_document_generation");
+ expect(sql).toContain("create or replace function public.is_committed_artifact_generation");
+ expect(sql).toContain("p_pages jsonb default null");
+ expect(sql).toContain("p_quality jsonb default null");
+ expect(sql).toContain("insert into public.document_pages");
+ expect(sql).toContain("insert into public.document_index_quality");
+ }
+ expect(schema).toContain("public.is_committed_document_generation(c.index_generation_id, d.metadata)");
+ expect(schema).toContain("public.is_committed_artifact_generation(m.metadata, d.metadata)");
+ expect(schema).toContain("public.is_committed_artifact_generation(f.metadata, d.metadata)");
+ expect(schema).toContain("public.is_committed_artifact_generation(u.metadata, d.metadata)");
+ expect(schema).toContain(
+ "grant execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) to service_role",
+ );
+ expect(atomicReindexMigration).toContain("atomic reindex patch did not match match_document_chunks_hybrid");
+ expect(atomicReindexMigration).toContain("atomic reindex patch did not match match_document_index_units_hybrid");
+ });
+
it("keeps indexing-v3 enrichment claiming separate from raw ingestion jobs", () => {
expect(schema).toContain("create table if not exists public.ingestion_job_stages");
expect(schema).toContain("drop constraint if exists ingestion_job_stages_job_id_fkey");
@@ -127,13 +153,30 @@ describe("Supabase schema Data API grants", () => {
expect(schema).toContain("state.enrichment_status in ('pending', 'failed', 'processing')");
expect(schema).toContain("'indexing_v3_agent_locked_by', p_worker_id");
expect(schema).toContain("'indexing_v3_agent_attempt_count', e.attempt_count + 1");
- expect(schema).toContain("grant execute on function public.claim_indexing_v3_agent_jobs(text, integer, integer) to service_role");
+ expect(schema).toContain(
+ "grant execute on function public.claim_indexing_v3_agent_jobs(text, integer, integer) to service_role",
+ );
expect(schema).toContain("alter table public.ingestion_job_stages enable row level security");
expect(schema).toContain('create policy "ingestion job stages service role all" on public.ingestion_job_stages');
const authenticatedSelectGrant = schema.match(/grant select on table ([^;]+) to authenticated;/)?.[1] ?? "";
expect(authenticatedSelectGrant).not.toContain("public.ingestion_job_stages");
});
+ it("keeps the cron indexing-v3 invoker in the schema snapshot with service-role-only execute grants", () => {
+ expect(schema).toContain("create or replace function public.invoke_indexing_v3_agent");
+ expect(schema).toContain("returns bigint");
+ expect(schema).toContain("security definer");
+ expect(schema).toContain("set search_path = public, extensions, vault, pg_temp");
+ expect(schema).toContain("from vault.decrypted_secrets");
+ expect(schema).toContain("where name = 'indexing_v3_agent_secret'");
+ expect(schema).toContain("select net.http_post(");
+ expect(schema).toContain("https://sjrfecxgysukkwxsowpy.supabase.co/functions/v1/indexing-v3-agent?limit=");
+ expect(schema).toContain(
+ "revoke execute on function public.invoke_indexing_v3_agent(integer) from public, anon, authenticated",
+ );
+ expect(schema).toContain("grant execute on function public.invoke_indexing_v3_agent(integer) to service_role");
+ });
+
it("drops the stale duplicate ingestion_job_stages document index", () => {
for (const sql of [schema, dropDuplicateStageIndexMigration]) {
expect(sql).toContain("drop index if exists public.ingestion_job_stages_doc_idx");
@@ -155,7 +198,9 @@ describe("Supabase schema Data API grants", () => {
expect(sql).toContain("case when title_embedding then null else 'title_embedding' end");
expect(sql).toContain("case when summary_embedding then null else 'summary_embedding' end");
expect(sql).toContain("or l.metadata->>'generated_by' = 'indexing-v3-agent'");
- expect(sql).toContain("lower(coalesce(l.metadata->>'generation_source', '')) = 'indexing_v3_agent_parsed_artifacts'");
+ expect(sql).toContain(
+ "lower(coalesce(l.metadata->>'generation_source', '')) = 'indexing_v3_agent_parsed_artifacts'",
+ );
expect(sql).toContain("'indexing_v3_agent_status', 'completed'");
expect(sql).toContain("'indexing_v3_agent_status', 'deferred'");
expect(sql).toContain("stage = 'strict_gate_deferred'");
@@ -163,8 +208,12 @@ describe("Supabase schema Data API grants", () => {
expect(sql).toContain("extraction_quality = 'good'");
expect(sql).toContain("revoke all on table public.document_strict_gate_status from public, anon, authenticated");
expect(sql).toContain("grant select on table public.document_strict_gate_status to service_role");
- expect(sql).toContain("revoke execute on function public.repair_strict_enrichment_gate_batch(integer) from public, anon, authenticated");
- expect(sql).toContain("grant execute on function public.repair_strict_enrichment_gate_batch(integer) to service_role");
+ expect(sql).toContain(
+ "revoke execute on function public.repair_strict_enrichment_gate_batch(integer) from public, anon, authenticated",
+ );
+ expect(sql).toContain(
+ "grant execute on function public.repair_strict_enrichment_gate_batch(integer) to service_role",
+ );
}
});
@@ -183,8 +232,12 @@ describe("Supabase schema Data API grants", () => {
expect(sql).toContain("on conflict on constraint document_index_quality_pkey");
expect(sql).toContain("'{}'::uuid[]");
expect(sql).not.toContain("perform public.refresh_import_batch_status(batch_ref)");
- expect(sql).toContain("revoke execute on function public.complete_strict_enrichment_job(uuid, uuid, text, text, text) from public, anon, authenticated");
- expect(sql).toContain("grant execute on function public.complete_strict_enrichment_job(uuid, uuid, text, text, text) to service_role");
+ expect(sql).toContain(
+ "revoke execute on function public.complete_strict_enrichment_job(uuid, uuid, text, text, text) from public, anon, authenticated",
+ );
+ expect(sql).toContain(
+ "grant execute on function public.complete_strict_enrichment_job(uuid, uuid, text, text, text) to service_role",
+ );
}
});
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index 006526e193..c59a4b89c4 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -449,7 +449,9 @@ async function waitForDemoDashboardReady(page: Page) {
async function openGuide(page: Page) {
const viewport = page.viewportSize();
const trigger =
- viewport && viewport.width >= 1024 ? page.locator("button:visible").filter({ hasText: "Guide & help" }).first() : null;
+ viewport && viewport.width >= 1024
+ ? page.locator("button:visible").filter({ hasText: "Guide & help" }).first()
+ : null;
const dialog = page.getByRole("dialog", { name: "Clinical KB guide" });
if (trigger) {
await expect(trigger).toBeVisible();
@@ -1133,7 +1135,10 @@ test.describe("Clinical KB UI smoke coverage", () => {
"/documents/11111111-1111-4111-8111-111111111111?page=1&chunk=44444444-4444-4444-8444-444444444442",
);
- await page.getByRole("button", { name: /^Answer from this(?: document)?$/ }).first().click();
+ await page
+ .getByRole("button", { name: /^Answer from this(?: document)?$/ })
+ .first()
+ .click();
const generatedSummary = page.getByTestId("generated-clinical-summary");
await expect(generatedSummary).toBeVisible();
diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts
index e65b9ef98b..5352df87cb 100644
--- a/tests/ui-stress.spec.ts
+++ b/tests/ui-stress.spec.ts
@@ -236,7 +236,9 @@ test.describe("Clinical KB long-content stress coverage", () => {
const dailyActions = await openDailyActions(page);
await dailyActions.getByRole("button", { name: "Add document" }).click();
const uploadSurface =
- viewport.name === "mobile" ? page.getByRole("dialog", { name: "Upload and indexing" }) : page.locator("#sources");
+ viewport.name === "mobile"
+ ? page.getByRole("dialog", { name: "Upload and indexing" })
+ : page.locator("#sources");
await expect(uploadSurface.getByText("24 indexed").first()).toBeVisible();
const closeUploadSheet = page.getByRole("button", { name: "Close Upload and indexing" });
if (await closeUploadSheet.isVisible().catch(() => false)) {
diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts
index 383d43e894..296b03f540 100644
--- a/tests/ui-tools.spec.ts
+++ b/tests/ui-tools.spec.ts
@@ -54,17 +54,6 @@ test.describe("Clinical KB applications launcher", () => {
});
}
- test("tools route redirects to applications", async ({ page }) => {
- await page.setViewportSize({ width: 1280, height: 900 });
- await gotoLauncher(page, "/tools");
-
- await expect(page).toHaveURL(/\/applications$/);
- await expect(page.getByRole("heading", { level: 1, name: "Applications" })).toBeVisible();
- await expect(page.getByTestId("selected-application-panel")).toContainText("Formulation");
- await expect(page.getByTestId("selected-application-panel").getByLabel("Launch Formulation")).toBeVisible();
- await expectNoPageHorizontalOverflow(page);
- });
-
test("launcher links point to the expected applications", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoLauncher(page);
diff --git a/tests/visual-intelligence.test.ts b/tests/visual-intelligence.test.ts
index 3dcf1f22bb..b4d3a34440 100644
--- a/tests/visual-intelligence.test.ts
+++ b/tests/visual-intelligence.test.ts
@@ -83,7 +83,9 @@ describe("visual intelligence v1", () => {
const selected = selectCaptionCandidateIndexes(ranked, 3, 2);
- const selectedGroups = [...selected].map((index) => ranked.find((candidate) => candidate.originalIndex === index)?.duplicateGroup);
+ const selectedGroups = [...selected].map(
+ (index) => ranked.find((candidate) => candidate.originalIndex === index)?.duplicateGroup,
+ );
expect(selectedGroups.filter((group) => group === "same-table")).toHaveLength(1);
});
diff --git a/tests/worker-visual-capture.test.ts b/tests/worker-visual-capture.test.ts
index 471a751118..8779cda199 100644
--- a/tests/worker-visual-capture.test.ts
+++ b/tests/worker-visual-capture.test.ts
@@ -7,32 +7,37 @@ const extractorSource = readFileSync(new URL("../worker/python/extract_pdf_asset
describe("worker visual capture hardening", () => {
it("guards every local worker vector write before Supabase inserts", () => {
expect(workerSource).toContain('import { assertEmbeddingDim } from "../src/lib/embedding-dimensions"');
- expect(workerSource).toContain('embedding: assertEmbeddingDim(embeddings[index], `document_chunks.${chunk.chunk_index}`)');
expect(workerSource).toContain(
- 'embedding: assertEmbeddingDim(embeddings[index], `document_embedding_fields.${field.field_type}`)',
+ "embedding: assertEmbeddingDim(embeddings[index], `document_chunks.${chunk.chunk_index}`)",
);
expect(workerSource).toContain(
- 'embedding: assertEmbeddingDim(fieldEmbeddings[index], `document_embedding_fields.section_context.${index}`)',
+ "embedding: assertEmbeddingDim(embeddings[index], `document_embedding_fields.${field.field_type}`)",
);
expect(workerSource).toContain(
- 'embedding: assertEmbeddingDim(unitEmbeddings[start + index], `document_index_units.visual.${start + index}`)',
+ "embedding: assertEmbeddingDim(fieldEmbeddings[index], `document_embedding_fields.section_context.${index}`)",
);
expect(workerSource).toContain(
- 'embedding: assertEmbeddingDim(additionalEmbeddings[index], `document_embedding_fields.${field.field_type}`)',
+ "embedding: assertEmbeddingDim(unitEmbeddings[start + index], `document_index_units.visual.${start + index}`)",
+ );
+ expect(workerSource).toContain(
+ "embedding: assertEmbeddingDim(additionalEmbeddings[index], `document_embedding_fields.${field.field_type}`)",
);
});
it("leaves optional artifact write failures claimable by the Supabase v3 repair agent", () => {
- expect(workerSource).toContain('const optionalRepairRequired = optionalIndexWriteIssues.length > 0');
- expect(workerSource).toContain('const agentRepairRequired = enrichmentStatus !== "completed" || optionalRepairRequired');
+ expect(workerSource).toContain("const optionalRepairRequired = optionalIndexWriteIssues.length > 0");
+ expect(workerSource).toContain(
+ 'const agentRepairRequired = enrichmentStatus !== "completed" || optionalRepairRequired',
+ );
expect(workerSource).toContain('enrichmentStatus = "pending"');
expect(workerSource).toContain('indexing_v3_agent_status: "pending"');
- expect(workerSource).toContain('indexing_v3_agent_repair_reason: "optional_index_write_issues"');
+ expect(workerSource).toContain('"optional_index_write_issues"');
+ expect(workerSource).toContain("indexing_v3_agent_repair_reason: agentRepairReason");
});
it("uses the strict completion RPC when inline enrichment succeeds", () => {
- expect(workerSource).toContain('async function completeStrictEnrichmentJob(job: JobRow)');
- expect(workerSource).toContain('complete_strict_enrichment_job');
+ expect(workerSource).toContain("async function completeStrictEnrichmentJob(job: JobRow)");
+ expect(workerSource).toContain("complete_strict_enrichment_job");
expect(workerSource).toContain('p_agent_version: "visual-core-v3"');
expect(workerSource).toContain('p_visual_indexing_version: "visual-v3"');
expect(workerSource).toContain('indexing_v3_agent_repair_reason: "strict_completion_gate_blocked"');
diff --git a/worker/main.ts b/worker/main.ts
index aa0ad4aca6..2656a5f4da 100644
--- a/worker/main.ts
+++ b/worker/main.ts
@@ -8,10 +8,7 @@ import { ragEnrichmentVersion, upsertDocumentEnrichment } from "../src/lib/docum
import { ragDeepMemoryVersion, upsertDocumentDeepMemory } from "../src/lib/deep-memory";
import { extractDocument } from "../src/lib/extractors/document";
import { assertEmbeddingDim } from "../src/lib/embedding-dimensions";
-import {
- buildVisualDocumentIndexUnitInputs,
- embeddingTextForDocumentIndexUnit,
-} from "../src/lib/document-index-units";
+import { buildVisualDocumentIndexUnitInputs, embeddingTextForDocumentIndexUnit } from "../src/lib/document-index-units";
import {
deterministicStructuredVisualProfile,
normalizeStructuredVisualProfile,
@@ -37,6 +34,7 @@ import {
import { assessDocumentIndexQuality } from "../src/lib/index-quality";
import { classifyAndCaptionImageFromBase64, embedTexts } from "../src/lib/openai";
import { safeErrorLogDetails, safeIngestionJobLog } from "../src/lib/privacy";
+import { isAtomicReindexCandidate } from "../src/lib/reindex-pipeline";
import { createAdminClient } from "../src/lib/supabase/admin";
import { probeSupabaseHealth } from "../src/lib/supabase/health";
import type { ExtractedDocument, ImageEvidenceCategory } from "../src/lib/types";
@@ -54,6 +52,7 @@ type JobDocument = {
content_hash?: string | null;
source_path?: string | null;
import_batch_id?: string | null;
+ status?: string | null;
metadata: Record | null;
};
@@ -240,7 +239,7 @@ async function completeStrictEnrichmentJob(job: JobRow) {
async function failOrRetryJob(args: {
job: JobRow;
retry: boolean;
- documentStatus: "queued" | "failed";
+ documentStatus: "queued" | "failed" | "indexed";
stage: string;
errorMessage: string;
nextRunAt?: string;
@@ -373,15 +372,58 @@ async function resetDocumentIndex(documentId: string) {
if (error) throw supabaseStageError("reset_document_index", error);
}
-async function insertPages(documentId: string, extracted: ExtractedDocument) {
- const pages = extracted.pages.map((page) => ({
+async function commitDocumentIndexGeneration(args: {
+ documentId: string;
+ indexGenerationId: string;
+ pageCount: number;
+ chunkCount: number;
+ imageCount: number;
+ metadata: Record;
+ pages: ReturnType;
+ quality: ReturnType;
+}) {
+ const { error } = await supabase.rpc("commit_document_index_generation", {
+ p_document_id: args.documentId,
+ p_index_generation_id: args.indexGenerationId,
+ p_status: "indexed",
+ p_page_count: args.pageCount,
+ p_chunk_count: args.chunkCount,
+ p_image_count: args.imageCount,
+ p_metadata: sanitizeJsonbRecord(args.metadata),
+ p_pages: args.pages.map((page) => ({
+ page_number: page.page_number,
+ text: page.text,
+ ocr_used: page.ocr_used,
+ metadata: sanitizeJsonbRecord(page.metadata),
+ })),
+ p_quality: sanitizeJsonbRecord(args.quality),
+ });
+ if (!error) return;
+ if (!isMissingSchemaError(error)) throw supabaseStageError("commit_document_index_generation", error);
+
+ await updateDocument(args.documentId, {
+ status: "indexed",
+ page_count: args.pageCount,
+ chunk_count: args.chunkCount,
+ image_count: args.imageCount,
+ error_message: null,
+ metadata: sanitizeJsonbRecord(args.metadata),
+ });
+ await insertPageRows(args.pages);
+ await upsertIndexQuality(args.quality);
+}
+
+function buildDocumentPageRows(documentId: string, extracted: ExtractedDocument) {
+ return extracted.pages.map((page) => ({
document_id: documentId,
page_number: page.pageNumber,
text: cleanString(page.text),
ocr_used: Boolean(page.ocrUsed),
metadata: {},
}));
+}
+async function insertPageRows(pages: ReturnType) {
if (pages.length === 0) return;
const { error } = await supabase.from("document_pages").upsert(pages, {
onConflict: "document_id,page_number",
@@ -389,6 +431,13 @@ async function insertPages(documentId: string, extracted: ExtractedDocument) {
if (error) throw supabaseStageError("upsert document_pages", error);
}
+async function upsertIndexQuality(quality: ReturnType) {
+ const { error } = await supabase.from("document_index_quality").upsert(sanitizeJsonbRecord(quality), {
+ onConflict: "document_id",
+ });
+ if (error) throw supabaseStageError("upsert document_index_quality", error);
+}
+
function hashBytes(bytes: Buffer) {
return createHash("sha256").update(bytes).digest("hex");
}
@@ -662,7 +711,12 @@ async function setCachedImageClassification(args: {
}
}
-async function uploadAndCaptionImages(job: JobRow, extracted: ExtractedDocument, pagesByNumber: Map) {
+async function uploadAndCaptionImages(
+ job: JobRow,
+ extracted: ExtractedDocument,
+ pagesByNumber: Map,
+ indexGenerationId: string,
+) {
const insertedImages: Array<{
id: string;
caption: string;
@@ -888,6 +942,7 @@ async function uploadAndCaptionImages(job: JobRow, extracted: ExtractedDocument,
metadata: sanitizeJsonbRecord({
...(image.metadata ?? {}),
extractor: "local-worker",
+ index_generation_id: indexGenerationId,
image_hash: imageHash,
perceptual_hash: perceptualHash,
classification_cache_hit: classificationCacheHit,
@@ -1091,6 +1146,7 @@ async function insertDocumentLevelEmbeddingFields(args: {
embedding: assertEmbeddingDim(embeddings[index], `document_embedding_fields.${field.field_type}`),
metadata: {
source: "document_level",
+ index_generation_id: args.chunkRows[0]?.index_generation_id ?? null,
},
}));
const { error } = await supabase.from("document_embedding_fields").insert(rows);
@@ -1100,9 +1156,9 @@ async function insertDocumentLevelEmbeddingFields(args: {
async function insertEmbeddedChunks(job: JobRow, extracted: ExtractedDocument) {
const pagesByNumber = new Map(extracted.pages.map((page) => [page.pageNumber, page.text] as const));
- const imageResult = await uploadAndCaptionImages(job, extracted, pagesByNumber);
- const { insertedImages } = imageResult;
const indexGenerationId = randomUUID();
+ const imageResult = await uploadAndCaptionImages(job, extracted, pagesByNumber, indexGenerationId);
+ const { insertedImages } = imageResult;
const optionalIndexWriteIssues: OptionalIndexWriteIssue[] = [];
await updateJob(job.id, { stage: "chunking", progress: 72 });
@@ -1170,7 +1226,7 @@ async function insertEmbeddedChunks(job: JobRow, extracted: ExtractedDocument) {
content: cleanString(field.content),
content_hash: hashEmbeddingFieldContent(cleanString(field.content)),
embedding: assertEmbeddingDim(fieldEmbeddings[index], `document_embedding_fields.section_context.${index}`),
- metadata: sanitizeJsonbRecord(field.metadata),
+ metadata: sanitizeJsonbRecord({ ...field.metadata, index_generation_id: indexGenerationId }),
}));
for (let start = 0; start < fieldRows.length; start += 50) {
const batch = fieldRows.slice(start, start + 50);
@@ -1190,7 +1246,7 @@ async function insertEmbeddedChunks(job: JobRow, extracted: ExtractedDocument) {
clinical_parameter: row.clinical_parameter ? cleanString(row.clinical_parameter) : null,
threshold_value: row.threshold_value ? cleanString(row.threshold_value) : null,
action: row.action ? cleanString(row.action) : null,
- metadata: sanitizeJsonbRecord(row.metadata),
+ metadata: sanitizeJsonbRecord({ ...row.metadata, index_generation_id: indexGenerationId }),
}));
if (tableFacts.length > 0) {
const { error: factsError } = await supabase.from("document_table_facts").insert(tableFacts);
@@ -1218,7 +1274,7 @@ async function insertEmbeddedChunks(job: JobRow, extracted: ExtractedDocument) {
const batch = visualIndexUnits.slice(start, start + 50).map((unit, index) => ({
...unit,
embedding: assertEmbeddingDim(unitEmbeddings[start + index], `document_index_units.visual.${start + index}`),
- metadata: sanitizeJsonbRecord(unit.metadata),
+ metadata: sanitizeJsonbRecord({ ...unit.metadata, index_generation_id: indexGenerationId }),
}));
const { error: visualUnitError } = await supabase.from("document_index_units").insert(batch);
if (visualUnitError) throw supabaseStageError("insert visual index units", visualUnitError);
@@ -1245,7 +1301,7 @@ async function insertEmbeddedChunks(job: JobRow, extracted: ExtractedDocument) {
content,
content_hash: hashEmbeddingFieldContent(content),
embedding: assertEmbeddingDim(additionalEmbeddings[index], `document_embedding_fields.${field.field_type}`),
- metadata: sanitizeJsonbRecord(field.metadata),
+ metadata: sanitizeJsonbRecord({ ...field.metadata, index_generation_id: indexGenerationId }),
};
});
for (let start = 0; start < additionalRows.length; start += 50) {
@@ -1331,16 +1387,21 @@ async function loadEnrichmentRows(documentId: string) {
}
async function processJob(job: JobRow) {
+ const atomicReindex = isAtomicReindexCandidate(job.documents);
await updateJobProgress(job.id, {
stage: "downloading",
progress: 5,
});
- await updateDocument(job.document_id, { status: "processing", error_message: null });
+ if (atomicReindex) {
+ await updateDocument(job.document_id, { error_message: null });
+ } else {
+ await updateDocument(job.document_id, { status: "processing", error_message: null });
+ }
await updateBatch(job.batch_id);
let extracted: ExtractedDocument | null = null;
try {
- await resetDocumentIndex(job.document_id);
+ if (!atomicReindex) await resetDocumentIndex(job.document_id);
const buffer = await downloadDocument(job.documents.storage_path);
await updateJobProgress(job.id, { stage: "extracting text/images", progress: 20 });
extracted = await extractDocument({
@@ -1350,7 +1411,7 @@ async function processJob(job: JobRow) {
});
await updateJobProgress(job.id, { stage: "saving pages", progress: 32 });
- await insertPages(job.document_id, extracted);
+ const pageRows = buildDocumentPageRows(job.document_id, extracted);
const {
chunks,
indexedChunkRows,
@@ -1373,40 +1434,37 @@ async function processJob(job: JobRow) {
memoryCardCount: 0,
optionalIndexWriteIssues,
});
- const { error: initialQualityError } = await supabase
- .from("document_index_quality")
- .upsert(sanitizeJsonbRecord(initialQuality), {
- onConflict: "document_id",
- });
- if (initialQualityError) throw new Error(initialQualityError.message);
const indexedAt = new Date().toISOString();
- await updateDocument(job.document_id, {
- status: "indexed",
- page_count: extracted.pages.length,
- chunk_count: chunks.length,
- image_count: imageCount,
- error_message: null,
- metadata: {
- ...(job.documents.metadata ?? {}),
- indexed_at: indexedAt,
- index_generation_id: indexGenerationId,
- rag_enrichment_version: ragEnrichmentVersion,
- rag_indexing_version: ragDeepMemoryVersion,
- rag_memory_version: ragDeepMemoryVersion,
- rag_memory_updated_at: null,
- rag_enrichment_updated_at: null,
- enrichment_status: "pending",
- section_count: 0,
- memory_card_count: 0,
- extraction_quality: initialQuality.extraction_quality,
- index_quality_score: initialQuality.quality_score,
- index_quality_issues: initialQuality.issues,
- index_quality_metrics: initialQuality.metrics,
- optional_index_write_issues: optionalIndexWriteIssues,
- embedding_model: env.OPENAI_EMBEDDING_MODEL,
- ...metrics,
- },
+ const committedCoreMetadata = {
+ ...(job.documents.metadata ?? {}),
+ indexed_at: indexedAt,
+ index_generation_id: indexGenerationId,
+ rag_enrichment_version: ragEnrichmentVersion,
+ rag_indexing_version: ragDeepMemoryVersion,
+ rag_memory_version: ragDeepMemoryVersion,
+ rag_memory_updated_at: null,
+ rag_enrichment_updated_at: null,
+ enrichment_status: "pending",
+ section_count: 0,
+ memory_card_count: 0,
+ extraction_quality: initialQuality.extraction_quality,
+ index_quality_score: initialQuality.quality_score,
+ index_quality_issues: initialQuality.issues,
+ index_quality_metrics: initialQuality.metrics,
+ optional_index_write_issues: optionalIndexWriteIssues,
+ embedding_model: env.OPENAI_EMBEDDING_MODEL,
+ ...metrics,
+ };
+ await commitDocumentIndexGeneration({
+ documentId: job.document_id,
+ indexGenerationId,
+ pageCount: extracted.pages.length,
+ chunkCount: chunks.length,
+ imageCount,
+ metadata: committedCoreMetadata,
+ pages: pageRows,
+ quality: initialQuality,
});
let enrichmentStatus = env.WORKER_INLINE_ENRICHMENT ? "completed" : "pending";
@@ -1452,12 +1510,7 @@ async function processJob(job: JobRow) {
documentEmbeddingFieldTypes,
optionalIndexWriteIssues,
});
- const { error: qualityError } = await supabase
- .from("document_index_quality")
- .upsert(sanitizeJsonbRecord(finalQuality), {
- onConflict: "document_id",
- });
- if (qualityError) throw new Error(qualityError.message);
+ await upsertIndexQuality(finalQuality);
enrichmentUpdatedAt = new Date().toISOString();
} catch (enrichmentError) {
enrichmentStatus = "failed";
@@ -1481,8 +1534,13 @@ async function processJob(job: JobRow) {
: enrichmentStatus === "failed"
? "inline_enrichment_failed"
: "enrichment_deferred";
+ const agentRepairMessage =
+ enrichmentErrorMessage ??
+ (optionalRepairRequired
+ ? optionalRepairMessage
+ : "Core index complete; enrichment queued for indexing-v3-agent.");
const finalMetadata = {
- ...(job.documents.metadata ?? {}),
+ ...committedCoreMetadata,
indexed_at: indexedAt,
index_generation_id: indexGenerationId,
rag_enrichment_version: ragEnrichmentVersion,
@@ -1502,7 +1560,7 @@ async function processJob(job: JobRow) {
...(agentRepairRequired
? {
indexing_v3_agent_status: "pending",
- indexing_v3_agent_last_error: enrichmentErrorMessage ?? optionalRepairMessage,
+ indexing_v3_agent_last_error: agentRepairMessage,
indexing_v3_agent_repair_reason: agentRepairReason,
indexing_v3_agent_updated_at: new Date().toISOString(),
}
@@ -1548,7 +1606,7 @@ async function processJob(job: JobRow) {
await failOrRetryJob({
job,
retry: false,
- documentStatus: "failed",
+ documentStatus: atomicReindex ? "indexed" : "failed",
stage: "needs recovery after partial index write",
errorMessage: `${message}. Run npm run recover:ingestion -- --apply before retrying this document.`,
});
@@ -1556,7 +1614,7 @@ async function processJob(job: JobRow) {
await failOrRetryJob({
job,
retry: true,
- documentStatus: "queued",
+ documentStatus: atomicReindex ? "indexed" : "queued",
stage: `retry scheduled after attempt ${job.attempt_count}/${job.max_attempts}`,
errorMessage: message,
nextRunAt: nextRetryAt(job.attempt_count),
@@ -1565,7 +1623,7 @@ async function processJob(job: JobRow) {
await failOrRetryJob({
job,
retry: false,
- documentStatus: "failed",
+ documentStatus: atomicReindex ? "indexed" : "failed",
stage: "failed",
errorMessage: message,
});
diff --git a/worker/table-facts.ts b/worker/table-facts.ts
index 4054355d07..9d412e1368 100644
--- a/worker/table-facts.ts
+++ b/worker/table-facts.ts
@@ -83,7 +83,12 @@ function firstRoleColumn(columns: string[], roles: Record | unde
return columns.findIndex((column) => normalizedCandidates.has(String(roles[column] ?? "").toLowerCase()));
}
-function firstColumnByRoleOrPattern(columns: string[], roles: Record | undefined, roleCandidates: string[], patterns: RegExp[]) {
+function firstColumnByRoleOrPattern(
+ columns: string[],
+ roles: Record | undefined,
+ roleCandidates: string[],
+ patterns: RegExp[],
+) {
const roleIndex = firstRoleColumn(columns, roles, roleCandidates);
return roleIndex >= 0 ? roleIndex : firstMatchingColumn(columns, patterns);
}
From 93ae934382a49cffc9773258fe3a5e658c85d364 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 28 Jun 2026 18:40:41 +0800
Subject: [PATCH 04/13] chore: update document organization mappings
Refine WA health site and program mappings used by document organization.
---
src/lib/document-organization.ts | 120 ++++++++++++++++++-------------
1 file changed, 70 insertions(+), 50 deletions(-)
diff --git a/src/lib/document-organization.ts b/src/lib/document-organization.ts
index 339033bad3..29c7f97723 100644
--- a/src/lib/document-organization.ts
+++ b/src/lib/document-organization.ts
@@ -37,83 +37,85 @@ const organizationProfileVersion = "document-organization-v1";
const siteDefinitions: SiteDefinition[] = [
// ── Individual hospitals ──────────────────────────────────────────────────
+ // RPBG = Royal Perth Bentley Group (RPH + Bentley Health Service) — 688 docs, most common tag
{
- canonical: "Royal Perth Hospital",
- rawTags: ["rph"],
+ canonical: "Royal Perth Bentley Group",
+ rawTags: ["rpbg"],
kind: "hospital",
- evidence: [/\broyal perth hospital\b/i, /\brph\b/i],
+ evidence: [/\broyal perth bentley\b/i, /\brpbg\b/i, /\broyal perth hospital\b/i, /\brph\b/i],
},
+ // AKG = Armadale Kalamunda Group — 197 docs
{
- canonical: "Sir Charles Gairdner Hospital",
- rawTags: ["scgh"],
- kind: "hospital",
- evidence: [/\bsir charles gairdner\b/i, /\bscgh\b/i],
- },
- {
- canonical: "Perth Children's Hospital",
- rawTags: ["pch"],
- kind: "hospital",
- evidence: [/\bperth children['']?s hospital\b/i, /\bpch\b/i],
- },
- {
- canonical: "King Edward Memorial Hospital",
- rawTags: ["kemh"],
+ canonical: "Armadale Kalamunda Group",
+ rawTags: ["akg"],
kind: "hospital",
- evidence: [/\bking edward memorial\b/i, /\bkemh\b/i],
+ evidence: [/\barmadale kalamunda\b/i, /\barmadale hospital\b/i, /\bakg\b/i],
},
+ // FSH = Fiona Stanley Hospital (includes Fremantle Hospital, FSFHG) — 340 docs
{
canonical: "Fiona Stanley Hospital",
rawTags: ["fsh"],
kind: "hospital",
- evidence: [/\bfiona stanley\b/i, /\bfsh\b/i],
+ evidence: [/\bfiona stanley\b/i, /\bfsh\b/i, /\bfsfhg\b/i],
},
+ // FH = Fremantle Hospital (legacy tag, 1 doc — now under FSH group)
{
canonical: "Fremantle Hospital",
rawTags: ["fh", "freo"],
kind: "hospital",
evidence: [/\bfremantle hospital\b/i, /\bfremantle health\b/i],
},
+ // PHC = Peel Health Campus — 156 docs
{
- canonical: "Armadale Kalamunda Group",
- rawTags: ["akg", "armadale"],
+ canonical: "Peel Health Campus",
+ rawTags: ["phc"],
kind: "hospital",
- evidence: [/\barmadale kalamunda\b/i, /\barmadale hospital\b/i, /\bakg\b/i],
+ evidence: [/\bpeel health campus\b/i, /\bpeel hospital\b/i, /\bphc\b/i],
},
+ // BHS = Bentley Health Service (subsidiary of RPBG, 1 doc)
{
- canonical: "Joondalup Health Campus",
- rawTags: ["jhc", "joondalup"],
+ canonical: "Bentley Health Service",
+ rawTags: ["bhs"],
kind: "hospital",
- evidence: [/\bjoondalup health campus\b/i, /\bjoondalup hospital\b/i, /\bjhc\b/i],
+ evidence: [/\bbentley health service\b/i, /\bbhs\b/i],
},
+ // KEMH = King Edward Memorial Hospital — 26 docs
{
- canonical: "Osborne Park Hospital",
- rawTags: ["oph", "osborne park"],
+ canonical: "King Edward Memorial Hospital",
+ rawTags: ["kemh"],
kind: "hospital",
- evidence: [/\bosborne park hospital\b/i, /\boph\b/i],
+ evidence: [/\bking edward memorial\b/i, /\bkemh\b/i],
},
+ // Other major metro hospitals (not yet in this dataset but worth including)
{
- canonical: "Swan / Midland Hospitals",
- rawTags: ["swan", "midland", "smh"],
+ canonical: "Sir Charles Gairdner Hospital",
+ rawTags: ["scgh"],
kind: "hospital",
- evidence: [/\bswan hospital\b/i, /\bmidland hospital\b/i, /\bst john of god midland\b/i],
+ evidence: [/\bsir charles gairdner\b/i, /\bscgh\b/i],
},
{
- canonical: "Bentley Hospital",
- rawTags: ["bentley"],
+ canonical: "Perth Children's Hospital",
+ rawTags: ["pch"],
kind: "hospital",
- evidence: [/\bbentley hospital\b/i],
+ evidence: [/\bperth children['']?s hospital\b/i, /\bpch\b/i],
},
{
- canonical: "Rockingham General Hospital",
- rawTags: ["rgh", "rockingham"],
+ canonical: "Joondalup Health Campus",
+ rawTags: ["jhc", "joondalup"],
kind: "hospital",
- evidence: [/\brockingham general hospital\b/i, /\brgh\b/i],
+ evidence: [/\bjoondalup health campus\b/i, /\bjhc\b/i],
},
{
- canonical: "Peel Health Campus",
- rawTags: ["phc", "peel"],
+ canonical: "Osborne Park Hospital",
+ rawTags: ["oph", "osborne park"],
+ kind: "hospital",
+ evidence: [/\bosborne park hospital\b/i, /\boph\b/i],
+ },
+ {
+ canonical: "Rockingham General Hospital",
+ rawTags: ["rgh"],
kind: "hospital",
- evidence: [/\bpeel health campus\b/i, /\bphc\b/i],
+ evidence: [/\brockingham general hospital\b/i, /\brgh\b/i],
},
{
canonical: "Graylands / Neuropsychiatric",
@@ -123,24 +125,34 @@ const siteDefinitions: SiteDefinition[] = [
},
// ── Health services / networks ────────────────────────────────────────────
+ // EMHS = East Metropolitan Health Service — 45 docs at network level
{
canonical: "East Metropolitan Health Service",
rawTags: ["emhs", "emhs policy"],
kind: "health_service",
evidence: [/\beast metropolitan health service\b/i, /\bemhs\b/i],
},
+ // SMHS = South Metropolitan Health Service (parent of FSH, Fremantle, CAMHS, RKPG)
{
canonical: "South Metropolitan Health Service",
rawTags: ["smhs", "smhs policy"],
kind: "health_service",
evidence: [/\bsouth metropolitan health service\b/i, /\bsmhs\b/i],
},
+ // NMHS = North Metropolitan Health Service — 262 docs
{
canonical: "North Metropolitan Health Service",
rawTags: ["nmhs", "nmhs policy"],
kind: "health_service",
evidence: [/\bnorth metropolitan health service\b/i, /\bnmhs\b/i],
},
+ // RKPG = Rockingham Peel Group — 168 docs
+ {
+ canonical: "Rockingham Peel Group",
+ rawTags: ["rkpg", "rockingham peel group"],
+ kind: "health_service",
+ evidence: [/\brockingham peel\b/i, /\brkpg\b/i],
+ },
{
canonical: "Child and Adolescent Health Service",
rawTags: ["cahs"],
@@ -154,34 +166,42 @@ const siteDefinitions: SiteDefinition[] = [
evidence: [/\bwa country health service\b/i, /\bwachs\b/i],
},
{
- canonical: "Rockingham Peel Group",
- rawTags: ["rkpg", "rockingham peel group"],
+ canonical: "WA Health",
+ rawTags: ["wah", "wa health", "doh"],
kind: "health_service",
- evidence: [/\brockingham peel\b/i, /\brkpg\b/i],
+ evidence: [/\bwa health\b/i, /\bdepartment of health\b/i, /\bdoh\b/i],
},
// ── Specialty programs / services ─────────────────────────────────────────
+ // CAMHS = Child and Adolescent Mental Health Service — 83 docs
{
canonical: "Child and Adolescent Mental Health Service",
rawTags: ["camhs"],
kind: "program",
evidence: [/\bchild and adolescent mental health\b/i, /\bcamhs\b/i],
},
+ // MHHITH = Mental Health Hospital in the Home — 3 docs
+ {
+ canonical: "Mental Health Hospital in the Home",
+ rawTags: ["mhhith"],
+ kind: "program",
+ evidence: [/\bmental health hospital in the home\b/i, /\bmhhith\b/i],
+ },
+ // PMHS = Peel Mental Health Service — 1 doc
+ {
+ canonical: "Peel Mental Health Service",
+ rawTags: ["pmhs"],
+ kind: "program",
+ evidence: [/\bpeel mental health service\b/i, /\bpmhs\b/i],
+ },
{
canonical: "Mental Health Commission",
rawTags: ["mhc"],
kind: "program",
evidence: [/\bmental health commission\b/i, /\bmhc\b/i],
},
- {
- canonical: "WA Health",
- rawTags: ["wah", "wa health", "doh"],
- kind: "health_service",
- evidence: [/\bwa health\b/i, /\bdepartment of health\b/i, /\bdoh\b/i],
- },
];
-
const secondaryTagMap = new Map([
["adult", { label: "adult", label_type: "population" }],
["child", { label: "child", label_type: "population" }],
From 4bb1bbdab3ac04711fcdfa4a807fde66c147155b Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 28 Jun 2026 18:43:46 +0800
Subject: [PATCH 05/13] chore: use organization profile helper in dashboard
Use the shared document organization profile helper for document drawer filters.
---
src/components/ClinicalDashboard.tsx | 25 ++++++++++++++-----------
1 file changed, 14 insertions(+), 11 deletions(-)
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index d8d7cf442a..578f2c612d 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -64,7 +64,11 @@ import {
useSyncExternalStore,
} from "react";
import { AccessibleTable } from "@/components/AccessibleTable";
-import { DocumentOrganizationBadges, documentDisplayTitle } from "@/components/DocumentOrganizationBadges";
+import {
+ DocumentOrganizationBadges,
+ documentDisplayTitle,
+ documentOrganizationProfile,
+} from "@/components/DocumentOrganizationBadges";
import { DocumentTagCloud } from "@/components/DocumentTagCloud";
import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions";
import { documentCitationHref, formatCompactCitationLabel, formatCitationLabel } from "@/lib/citations";
@@ -204,7 +208,6 @@ import type {
SearchScopeSummary,
VisualEvidenceCard,
ClinicalQueryMode,
- DocumentOrganizationProfile,
} from "@/lib/types";
import type { SearchScopeFilters } from "@/lib/search-scope";
import {
@@ -4187,7 +4190,7 @@ function DocumentDrawer({
for (const doc of documents) {
const typeLabel = doc.labels?.find((l) => l.label_type === "document_type" && l.confidence >= 0.5)?.label;
if (typeLabel) types.add(typeLabel);
- const profile = (doc.metadata as { organization_profile?: DocumentOrganizationProfile })?.organization_profile;
+ const profile = documentOrganizationProfile(doc);
if (profile?.document_type?.label && profile.document_type.label !== "unknown") {
types.add(profile.document_type.label);
}
@@ -4200,7 +4203,7 @@ function DocumentDrawer({
for (const doc of documents) {
const siteLabels = doc.labels?.filter((l) => l.label_type === "site" && l.confidence >= 0.5) ?? [];
for (const l of siteLabels) sites.add(l.label);
- const profile = (doc.metadata as { organization_profile?: DocumentOrganizationProfile })?.organization_profile;
+ const profile = documentOrganizationProfile(doc);
if (profile?.site?.label) sites.add(profile.site.label);
}
return Array.from(sites).sort();
@@ -4212,7 +4215,7 @@ function DocumentDrawer({
const topicLabels =
doc.labels?.filter((l) => (l.label_type === "topic" || l.label_type === "custom") && l.confidence >= 0.5) ?? [];
for (const l of topicLabels) topics.add(l.label);
- const profile = (doc.metadata as { organization_profile?: DocumentOrganizationProfile })?.organization_profile;
+ const profile = documentOrganizationProfile(doc);
if (profile?.secondary_facets?.topic) {
for (const t of profile.secondary_facets.topic) topics.add(t);
}
@@ -4225,7 +4228,7 @@ function DocumentDrawer({
for (const doc of documents) {
const popLabels = doc.labels?.filter((l) => l.label_type === "population" && l.confidence >= 0.5) ?? [];
for (const l of popLabels) populations.add(l.label);
- const profile = (doc.metadata as { organization_profile?: DocumentOrganizationProfile })?.organization_profile;
+ const profile = documentOrganizationProfile(doc);
if (profile?.secondary_facets?.population) {
for (const p of profile.secondary_facets.population) populations.add(p);
}
@@ -4263,7 +4266,7 @@ function DocumentDrawer({
// Filter by Type
if (selectedType !== "all") {
const typeLabel = document.labels?.find((l) => l.label_type === "document_type" && l.confidence >= 0.5)?.label;
- const profile = (document.metadata as { organization_profile?: DocumentOrganizationProfile })?.organization_profile;
+ const profile = documentOrganizationProfile(document);
const hasTypeMatch = typeLabel === selectedType || profile?.document_type?.label === selectedType;
if (!hasTypeMatch) return false;
}
@@ -4271,7 +4274,7 @@ function DocumentDrawer({
// Filter by Site
if (selectedSite !== "all") {
const siteLabels = document.labels?.filter((l) => l.label_type === "site" && l.confidence >= 0.5) ?? [];
- const profile = (document.metadata as { organization_profile?: DocumentOrganizationProfile })?.organization_profile;
+ const profile = documentOrganizationProfile(document);
const hasSiteMatch = siteLabels.some((l) => l.label === selectedSite) || profile?.site?.label === selectedSite;
if (!hasSiteMatch) return false;
}
@@ -4282,7 +4285,7 @@ function DocumentDrawer({
document.labels?.filter(
(l) => (l.label_type === "topic" || l.label_type === "custom") && l.confidence >= 0.5,
) ?? [];
- const profile = (document.metadata as { organization_profile?: DocumentOrganizationProfile })?.organization_profile;
+ const profile = documentOrganizationProfile(document);
const hasTopicMatch =
topicLabels.some((l) => l.label === selectedTopic) ||
profile?.secondary_facets?.topic?.includes(selectedTopic);
@@ -4292,7 +4295,7 @@ function DocumentDrawer({
// Filter by Population
if (selectedPopulation !== "all") {
const popLabels = document.labels?.filter((l) => l.label_type === "population" && l.confidence >= 0.5) ?? [];
- const profile = (document.metadata as { organization_profile?: DocumentOrganizationProfile })?.organization_profile;
+ const profile = documentOrganizationProfile(document);
const hasPopMatch =
popLabels.some((l) => l.label === selectedPopulation) ||
profile?.secondary_facets?.population?.includes(selectedPopulation);
@@ -4301,7 +4304,7 @@ function DocumentDrawer({
// Filter by Needs Review
if (showNeedsReviewOnly) {
- const profile = (document.metadata as { organization_profile?: DocumentOrganizationProfile })?.organization_profile;
+ const profile = documentOrganizationProfile(document);
if (profile?.review_status !== "needs_review") return false;
}
From 5714f6e4b73e2bd54cfcd282f958bf2d1a941f06 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 28 Jun 2026 18:46:40 +0800
Subject: [PATCH 06/13] test: stabilize local smoke setup
Stabilize local project identity mocking in UI smoke tests and broaden generated document classification labels.
---
scripts/classify-documents.ts | 19 ++++++++++++++++---
tests/ui-smoke.spec.ts | 31 +++++++++++++++++++++++++++----
vitest.config.mts | 4 +++-
3 files changed, 46 insertions(+), 8 deletions(-)
diff --git a/scripts/classify-documents.ts b/scripts/classify-documents.ts
index bed6db32aa..1954f676f6 100644
--- a/scripts/classify-documents.ts
+++ b/scripts/classify-documents.ts
@@ -130,17 +130,30 @@ async function writeClassification(
.eq("status", "indexed");
if (documentError) throw new Error(documentError.message);
+ // Delete all previously generated labels for this document (all types)
const { error: deleteError } = await supabase
.from("document_labels")
.delete()
.eq("document_id", document.id)
.eq("source", "generated")
- .eq("label_type", "site");
+ .in("label_type", ["site", "document_type", "population", "topic", "setting", "service", "workflow"]);
if (deleteError) throw new Error(deleteError.message);
- const generatedLabels = classification.labels.filter(
- (label) => label.label_type === "site" && label.confidence >= 0.75,
+ // Write site labels (confident only, >= 0.75)
+ const siteLabels = classification.labels.filter((label) => label.label_type === "site" && label.confidence >= 0.75);
+
+ // Write document_type labels (any confidence >= 0.5 — so even needs_review types are captured)
+ const typeLabels = classification.labels.filter(
+ (label) => label.label_type === "document_type" && label.confidence >= 0.5,
+ );
+
+ // Write all secondary facet labels (population, topic, setting, service, workflow)
+ const secondaryLabels = classification.labels.filter(
+ (label) =>
+ ["population", "topic", "setting", "service", "workflow"].includes(label.label_type) && label.confidence >= 0.5,
);
+
+ const generatedLabels = [...siteLabels, ...typeLabels, ...secondaryLabels];
if (!generatedLabels.length) return;
const { error: labelError } = await supabase.from("document_labels").upsert(
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index c59a4b89c4..a23ead911c 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -91,6 +91,28 @@ const readySetupChecks = [
{ id: "worker", label: "npm run worker running", status: "unknown", detail: "Worker not required for UI smoke." },
];
+async function mockLocalProjectIdentity(page: Page) {
+ await page.route(/\/api\/local-project-id$/, async (route) => {
+ await route.fulfill({
+ json: {
+ appName: "Clinical KB",
+ projectId: "test-project",
+ identityPath: "/api/local-project-id",
+ localServer: {
+ currentUrl: "http://localhost:4298",
+ currentPort: 4298,
+ projectPortStart: 4298,
+ projectPortEnd: 53210,
+ safeLocalOrigin: true,
+ requestOrigin: null,
+ requestReferer: null,
+ unsafeLocalCaller: null,
+ },
+ },
+ });
+ });
+}
+
async function mockPrivateUnauthenticatedApi(page: Page) {
await page.route("**/api/setup-status**", async (route) => {
await route.fulfill({
@@ -133,6 +155,7 @@ async function fulfillAnswerResponse(route: Route, payload: unknown) {
}
async function mockDemoApi(page: Page) {
+ await mockLocalProjectIdentity(page);
await page.route("**/api/setup-status**", async (route) => {
await route.fulfill({
json: { demoMode: true, checks: readySetupChecks },
@@ -596,14 +619,14 @@ test.describe("Clinical KB UI smoke coverage", () => {
const dailyActionsTrigger = page.getByRole("button", { name: "Open daily actions" });
const dailyActionsMenu = page.getByTestId("daily-actions-menu");
- await dailyActionsTrigger.click();
- await expect(dailyActionsMenu).toBeVisible();
+ // First open — use robust retry helper to handle async state update timing.
+ await openDailyActions(page);
await visibleQuestionInput(page).click();
await expect(dailyActionsMenu).toHaveCount(0);
await expect(dailyActionsTrigger).toHaveAttribute("aria-expanded", "false");
- await dailyActionsTrigger.click();
- await expect(dailyActionsMenu).toBeVisible();
+ // Second open — verify closing via scope trigger.
+ await openDailyActions(page);
await scopeTrigger(page).click();
await expect(dailyActionsMenu).toHaveCount(0);
diff --git a/vitest.config.mts b/vitest.config.mts
index 2342f72ef0..ef04147123 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -1,4 +1,4 @@
-export default {
+const config = {
test: {
testTimeout: 15000,
coverage: {
@@ -17,3 +17,5 @@ export default {
},
},
};
+
+export default config;
From 09c596ae79a8af318c9e1327c1b21bd4bd69460c Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 28 Jun 2026 18:49:05 +0800
Subject: [PATCH 07/13] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
worker/main.ts | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/worker/main.ts b/worker/main.ts
index 2656a5f4da..e1abfc976d 100644
--- a/worker/main.ts
+++ b/worker/main.ts
@@ -401,6 +401,12 @@ async function commitDocumentIndexGeneration(args: {
if (!error) return;
if (!isMissingSchemaError(error)) throw supabaseStageError("commit_document_index_generation", error);
+ const { error: deletePagesError } = await supabase
+ .from("document_pages")
+ .delete()
+ .eq("document_id", args.documentId);
+ if (deletePagesError) throw supabaseStageError("delete document_pages", deletePagesError);
+
await updateDocument(args.documentId, {
status: "indexed",
page_count: args.pageCount,
@@ -409,6 +415,7 @@ async function commitDocumentIndexGeneration(args: {
error_message: null,
metadata: sanitizeJsonbRecord(args.metadata),
});
+
await insertPageRows(args.pages);
await upsertIndexQuality(args.quality);
}
From f125d16b7ba1f57a3b8128180e22ce0f2d442624 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 28 Jun 2026 18:49:13 +0800
Subject: [PATCH 08/13] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
.../20260628000000_atomic_reindex_generation_commit.sql | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql b/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql
index a914cd2403..0d8ce58105 100644
--- a/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql
+++ b/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql
@@ -167,9 +167,12 @@ begin
end;
$$;
+revoke execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) from public, anon, authenticated;
grant execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) to service_role;
+revoke execute on function public.is_committed_document_generation(uuid, jsonb) from public, anon, authenticated;
grant execute on function public.is_committed_document_generation(uuid, jsonb) to service_role;
-grant execute on function public.is_committed_artifact_generation(jsonb, jsonb) to service_role;
+revoke execute on function public.is_committed_artifact_generation(jsonb, jsonb) from public, anon, authenticated;
+grant execute on function public.is_committed_artifact_generation(jsonb, jsonb) to service_role
do $$
declare
From 9451eb74fc2add49f9f30bc941ae479a687ce4e7 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 28 Jun 2026 18:49:20 +0800
Subject: [PATCH 09/13] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
supabase/schema.sql | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/supabase/schema.sql b/supabase/schema.sql
index f53080f3ff..3b0a9fa59a 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -3364,9 +3364,12 @@ alter table public.document_index_units enable row level security;
grant select, insert, update, delete on table public.document_index_units to service_role;
grant select on table public.document_index_units to authenticated;
grant execute on function public.match_document_index_units_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid) to service_role;
+revoke execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) from public, anon, authenticated;
grant execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) to service_role;
+revoke execute on function public.is_committed_document_generation(uuid, jsonb) from public, anon, authenticated;
grant execute on function public.is_committed_document_generation(uuid, jsonb) to service_role;
-grant execute on function public.is_committed_artifact_generation(jsonb, jsonb) to service_role;
+revoke execute on function public.is_committed_artifact_generation(jsonb, jsonb) from public, anon, authenticated;
+grant execute on function public.is_committed_artifact_generation(jsonb, jsonb) to service_role
create policy "document index units owner read" on public.document_index_units
for select to authenticated using (
From b159f53aa34ede91a12e5a08ed659f016ff8cea3 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 28 Jun 2026 18:49:28 +0800
Subject: [PATCH 10/13] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
src/lib/deep-memory.ts | 30 ++++++++++++++++--------------
1 file changed, 16 insertions(+), 14 deletions(-)
diff --git a/src/lib/deep-memory.ts b/src/lib/deep-memory.ts
index ec814beed9..b14a0c02f2 100644
--- a/src/lib/deep-memory.ts
+++ b/src/lib/deep-memory.ts
@@ -853,20 +853,22 @@ export async function fetchMemoryCardsForQuery(args: {
const cards = (data ?? []) as DocumentMemoryCard[];
const documentIds = Array.from(new Set(cards.map((card) => card.document_id)));
- const { data: documents } = documentIds.length
- ? await args.supabase.from("documents").select("id,metadata").in("id", documentIds)
- : { data: [] };
- const committedGenerationByDocument = new Map(
- (documents ?? []).map((document) => [document.id, committedIndexGeneration(document.metadata)] as const),
- );
-
- return cards
- .filter((card) =>
- isCommittedGenerationMetadata({
- rowMetadata: card.metadata,
- committedGeneration: committedGenerationByDocument.get(card.document_id),
- }),
- )
+const { data: documents, error: documentsError } = documentIds.length
+ ? await args.supabase.from("documents").select("id,metadata").in("id", documentIds)
+ : { data: [], error: null };
+const committedGenerationByDocument = new Map(
+ (documents ?? []).map((document) => [document.id, committedIndexGeneration(document.metadata)] as const),
+);
+
+return cards
+ .filter((card) => {
+ if (documentsError) return true;
+ if (!committedGenerationByDocument.has(card.document_id)) return true;
+ return isCommittedGenerationMetadata({
+ rowMetadata: card.metadata,
+ committedGeneration: committedGenerationByDocument.get(card.document_id),
+ });
+ })
.map((card) => ({ ...card, confidence: Number(card.confidence ?? 0.5) }))
.sort((a, b) => scoreMemoryCardForQuery(args.query, b) - scoreMemoryCardForQuery(args.query, a))
.slice(0, args.matchCount ?? 32);
From 4b76f51401c51bb13fc7c9017768f7abd975058a Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 28 Jun 2026 19:06:26 +0800
Subject: [PATCH 11/13] fix: isolate atomic reindex generations
---
src/app/api/documents/[id]/route.ts | 14 +-
src/app/api/documents/[id]/search/route.ts | 28 ++-
.../api/documents/[id]/table-facts/route.ts | 27 ++-
src/app/api/images/[id]/signed-url/route.ts | 13 +-
src/lib/deep-memory.ts | 32 +--
...00000_atomic_reindex_generation_commit.sql | 2 +-
supabase/schema.sql | 2 +-
tests/deep-memory.test.ts | 65 ++++++
tests/private-access-routes.test.ts | 195 +++++++++++++++++-
tests/supabase-schema.test.ts | 11 +
tests/worker-visual-capture.test.ts | 9 +
worker/main.ts | 65 +++++-
12 files changed, 418 insertions(+), 45 deletions(-)
diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts
index 3fc34fcffa..95799522de 100644
--- a/src/app/api/documents/[id]/route.ts
+++ b/src/app/api/documents/[id]/route.ts
@@ -4,6 +4,7 @@ import { getDemoDocumentPayload } from "@/lib/demo-data";
import { env, isDemoMode } from "@/lib/env";
import { jsonError, PublicApiError } from "@/lib/http";
import { invalidateRagCachesForDocumentMutation } from "@/lib/rag";
+import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
@@ -119,6 +120,11 @@ function withImageTableMetadata(image: T) {
};
}
+function committedRows(document: { metadata?: unknown }, rows: T[]) {
+ const committedGeneration = committedIndexGeneration(document.metadata);
+ return rows.filter((row) => isCommittedGenerationMetadata({ rowMetadata: row.metadata, committedGeneration }));
+}
+
function storageWarningsFrom(error: unknown, label: string) {
const message =
error && typeof error === "object" && "message" in error ? String(error.message) : "Storage cleanup failed.";
@@ -305,7 +311,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
.maybeSingle();
if (selectedChunkError) throw new Error(selectedChunkError.message);
- selectedChunk = data ?? null;
+ selectedChunk = data && committedRows(document, [data]).length > 0 ? data : null;
}
const effectivePage = selectedChunk?.page_number ?? requestedPage;
@@ -373,9 +379,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
summary: summaryResult.data ?? null,
},
pages: pages ?? [],
- images: (images ?? []).map(withImageTableMetadata),
- tableFacts: tableFactsResult.data ?? [],
- chunks: chunks ?? [],
+ images: committedRows(document, images ?? []).map(withImageTableMetadata),
+ tableFacts: committedRows(document, tableFactsResult.data ?? []),
+ chunks: committedRows(document, chunks ?? []),
pageWindow: {
from: pageWindow.from,
to: pageWindow.to,
diff --git a/src/app/api/documents/[id]/search/route.ts b/src/app/api/documents/[id]/search/route.ts
index d7a601594e..159cdd5942 100644
--- a/src/app/api/documents/[id]/search/route.ts
+++ b/src/app/api/documents/[id]/search/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { demoChunks, getDemoDocument } from "@/lib/demo-data";
import { isDemoMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
+import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
@@ -16,6 +17,8 @@ type DocumentChunkSearchRow = {
image_ids: string[] | null;
text_rank?: number | null;
trigram_score?: number | null;
+ metadata?: Record | null;
+ index_generation_id?: string | null;
};
const maxSearchTerms = 8;
@@ -139,6 +142,10 @@ function resultFromChunk(row: DocumentChunkSearchRow, query: string, terms: stri
};
}
+function generationMetadataForRow(row: DocumentChunkSearchRow) {
+ return row.index_generation_id ? { index_generation_id: row.index_generation_id } : row.metadata;
+}
+
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
@@ -174,13 +181,14 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
const user = await requireAuthenticatedUser(request, supabase);
const { data: document, error: documentError } = await supabase
.from("documents")
- .select("id")
+ .select("id,metadata")
.eq("id", id)
.eq("owner_id", user.id)
.maybeSingle();
if (documentError) throw new Error(documentError.message);
if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 });
+ const committedGeneration = committedIndexGeneration(document.metadata);
const { data: rpcData, error: rpcError } = await supabase.rpc("search_document_chunks", {
p_document_id: id,
@@ -191,6 +199,12 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
if (!rpcError) {
const results = ((rpcData ?? []) as DocumentChunkSearchRow[])
+ .filter((row) =>
+ isCommittedGenerationMetadata({
+ rowMetadata: generationMetadataForRow(row),
+ committedGeneration,
+ }),
+ )
.map((row) => resultFromChunk(row, query, terms))
.filter((result) => result.score > 0)
.sort(
@@ -219,7 +233,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
const queryBuilder = supabase
.from("document_chunks")
- .select("id,page_number,chunk_index,section_heading,content,image_ids")
+ .select("id,page_number,chunk_index,section_heading,content,image_ids,metadata,index_generation_id")
.eq("document_id", id)
.order("chunk_index", { ascending: true })
.limit(Math.min(maxSearchLimit * 3, Math.max(limit * 3, limit)));
@@ -228,11 +242,17 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
if (error) throw new Error(error.message);
const importantTerms = importantTermsFor(terms);
- const candidateRows = ((data ?? []) as DocumentChunkSearchRow[]).filter((row) => {
+ const committedData = ((data ?? []) as DocumentChunkSearchRow[]).filter((row) =>
+ isCommittedGenerationMetadata({
+ rowMetadata: generationMetadataForRow(row),
+ committedGeneration,
+ }),
+ );
+ const candidateRows = committedData.filter((row) => {
if (importantTerms.length <= 1) return true;
return importantTerms.every((term) => coveredTermsFor(row, [term]).length > 0);
});
- const fallbackRows = candidateRows.length ? candidateRows : ((data ?? []) as DocumentChunkSearchRow[]);
+ const fallbackRows = candidateRows.length ? candidateRows : committedData;
const results = fallbackRows
.map((row) => resultFromChunk(row, query, terms))
.filter((result) => {
diff --git a/src/app/api/documents/[id]/table-facts/route.ts b/src/app/api/documents/[id]/table-facts/route.ts
index db6cc0f9bf..3eb1166b0a 100644
--- a/src/app/api/documents/[id]/table-facts/route.ts
+++ b/src/app/api/documents/[id]/table-facts/route.ts
@@ -3,6 +3,7 @@ import { z } from "zod";
import { isDemoMode } from "@/lib/env";
import { jsonError, PublicApiError } from "@/lib/http";
import { invalidateRagCachesForOwner } from "@/lib/rag";
+import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import { tableReviewMetadata, tableReviewSchema } from "@/lib/table-review";
@@ -17,19 +18,19 @@ function metadataRecord(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Record) } : {};
}
-async function assertDocumentOwner(args: {
+async function loadOwnedDocument(args: {
supabase: ReturnType;
documentId: string;
ownerId: string;
}) {
const { data, error } = await args.supabase
.from("documents")
- .select("id")
+ .select("id,metadata")
.eq("id", args.documentId)
.eq("owner_id", args.ownerId)
.maybeSingle();
if (error) throw new Error(error.message);
- return Boolean(data);
+ return data;
}
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
@@ -39,9 +40,11 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);
- if (!(await assertDocumentOwner({ supabase, documentId: id, ownerId: user.id }))) {
+ const document = await loadOwnedDocument({ supabase, documentId: id, ownerId: user.id });
+ if (!document) {
return NextResponse.json({ error: "Document not found." }, { status: 404 });
}
+ const committedGeneration = committedIndexGeneration(document.metadata);
const { data, error } = await supabase
.from("document_table_facts")
@@ -50,7 +53,11 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
.order("page_number", { ascending: true })
.order("created_at", { ascending: true });
if (error) throw new Error(error.message);
- return NextResponse.json({ tableFacts: data ?? [] });
+ return NextResponse.json({
+ tableFacts: (data ?? []).filter((fact) =>
+ isCommittedGenerationMetadata({ rowMetadata: fact.metadata, committedGeneration }),
+ ),
+ });
} catch (error) {
if (error instanceof AuthenticationError) return unauthorizedResponse();
return jsonError(error);
@@ -67,9 +74,11 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);
- if (!(await assertDocumentOwner({ supabase, documentId: id, ownerId: user.id }))) {
+ const document = await loadOwnedDocument({ supabase, documentId: id, ownerId: user.id });
+ if (!document) {
return NextResponse.json({ error: "Document not found." }, { status: 404 });
}
+ const committedGeneration = committedIndexGeneration(document.metadata);
const { data: fact, error: factError } = await supabase
.from("document_table_facts")
@@ -80,6 +89,9 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
.maybeSingle();
if (factError) throw new Error(factError.message);
if (!fact) return NextResponse.json({ error: "Table fact not found." }, { status: 404 });
+ if (!isCommittedGenerationMetadata({ rowMetadata: fact.metadata, committedGeneration })) {
+ return NextResponse.json({ error: "Table fact not found." }, { status: 404 });
+ }
const reviewMetadata = tableReviewMetadata({
reviewClass: parsed.data.reviewClass,
@@ -105,6 +117,9 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
.eq("document_id", id)
.maybeSingle();
if (image) {
+ if (!isCommittedGenerationMetadata({ rowMetadata: image.metadata, committedGeneration })) {
+ return NextResponse.json({ error: "Table fact not found." }, { status: 404 });
+ }
await supabase
.from("document_images")
.update({
diff --git a/src/app/api/images/[id]/signed-url/route.ts b/src/app/api/images/[id]/signed-url/route.ts
index 3f1a6d254d..b061aaf8a2 100644
--- a/src/app/api/images/[id]/signed-url/route.ts
+++ b/src/app/api/images/[id]/signed-url/route.ts
@@ -4,6 +4,7 @@ import { getDemoImage } from "@/lib/demo-data";
import { env } from "@/lib/env";
import { isDemoMode } from "@/lib/env";
import { jsonError, PublicApiError } from "@/lib/http";
+import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
@@ -33,7 +34,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
const user = await requireAuthenticatedUser(_request, supabase);
const { data: image, error } = await supabase
.from("document_images")
- .select("document_id,storage_path,mime_type,caption")
+ .select("document_id,storage_path,mime_type,caption,metadata")
.eq("id", id)
.maybeSingle();
@@ -42,13 +43,21 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
const { data: document, error: documentError } = await supabase
.from("documents")
- .select("id")
+ .select("id,metadata")
.eq("id", image.document_id)
.eq("owner_id", user.id)
.maybeSingle();
if (documentError) throw new Error(documentError.message);
if (!document) return NextResponse.json({ error: "Image not found." }, { status: 404 });
+ if (
+ !isCommittedGenerationMetadata({
+ rowMetadata: image.metadata,
+ committedGeneration: committedIndexGeneration(document.metadata),
+ })
+ ) {
+ return NextResponse.json({ error: "Image not found." }, { status: 404 });
+ }
const signed = await supabase.storage
.from(env.SUPABASE_IMAGE_BUCKET)
diff --git a/src/lib/deep-memory.ts b/src/lib/deep-memory.ts
index b14a0c02f2..fe2ed743cd 100644
--- a/src/lib/deep-memory.ts
+++ b/src/lib/deep-memory.ts
@@ -853,22 +853,22 @@ export async function fetchMemoryCardsForQuery(args: {
const cards = (data ?? []) as DocumentMemoryCard[];
const documentIds = Array.from(new Set(cards.map((card) => card.document_id)));
-const { data: documents, error: documentsError } = documentIds.length
- ? await args.supabase.from("documents").select("id,metadata").in("id", documentIds)
- : { data: [], error: null };
-const committedGenerationByDocument = new Map(
- (documents ?? []).map((document) => [document.id, committedIndexGeneration(document.metadata)] as const),
-);
-
-return cards
- .filter((card) => {
- if (documentsError) return true;
- if (!committedGenerationByDocument.has(card.document_id)) return true;
- return isCommittedGenerationMetadata({
- rowMetadata: card.metadata,
- committedGeneration: committedGenerationByDocument.get(card.document_id),
- });
- })
+ const { data: documents, error: documentsError } = documentIds.length
+ ? await args.supabase.from("documents").select("id,metadata").in("id", documentIds)
+ : { data: [], error: null };
+ const committedGenerationByDocument = new Map(
+ (documents ?? []).map((document) => [document.id, committedIndexGeneration(document.metadata)] as const),
+ );
+
+ return cards
+ .filter((card) => {
+ if (documentsError) return true;
+ if (!committedGenerationByDocument.has(card.document_id)) return true;
+ return isCommittedGenerationMetadata({
+ rowMetadata: card.metadata,
+ committedGeneration: committedGenerationByDocument.get(card.document_id),
+ });
+ })
.map((card) => ({ ...card, confidence: Number(card.confidence ?? 0.5) }))
.sort((a, b) => scoreMemoryCardForQuery(args.query, b) - scoreMemoryCardForQuery(args.query, a))
.slice(0, args.matchCount ?? 32);
diff --git a/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql b/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql
index 0d8ce58105..1c37c036b9 100644
--- a/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql
+++ b/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql
@@ -172,7 +172,7 @@ grant execute on function public.commit_document_index_generation(uuid, uuid, te
revoke execute on function public.is_committed_document_generation(uuid, jsonb) from public, anon, authenticated;
grant execute on function public.is_committed_document_generation(uuid, jsonb) to service_role;
revoke execute on function public.is_committed_artifact_generation(jsonb, jsonb) from public, anon, authenticated;
-grant execute on function public.is_committed_artifact_generation(jsonb, jsonb) to service_role
+grant execute on function public.is_committed_artifact_generation(jsonb, jsonb) to service_role;
do $$
declare
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 3b0a9fa59a..987a0aaade 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -3369,7 +3369,7 @@ grant execute on function public.commit_document_index_generation(uuid, uuid, te
revoke execute on function public.is_committed_document_generation(uuid, jsonb) from public, anon, authenticated;
grant execute on function public.is_committed_document_generation(uuid, jsonb) to service_role;
revoke execute on function public.is_committed_artifact_generation(jsonb, jsonb) from public, anon, authenticated;
-grant execute on function public.is_committed_artifact_generation(jsonb, jsonb) to service_role
+grant execute on function public.is_committed_artifact_generation(jsonb, jsonb) to service_role;
create policy "document index units owner read" on public.document_index_units
for select to authenticated using (
diff --git a/tests/deep-memory.test.ts b/tests/deep-memory.test.ts
index d769f788b7..bd002a9c23 100644
--- a/tests/deep-memory.test.ts
+++ b/tests/deep-memory.test.ts
@@ -4,6 +4,7 @@ import {
applyMemoryCardBoosts,
buildDocumentMemoryCards,
buildDocumentSections,
+ fetchMemoryCardsForQuery,
ragDeepMemoryVersion,
upsertDocumentDeepMemory,
} from "../src/lib/deep-memory";
@@ -233,6 +234,70 @@ describe("deep RAG memory indexing", () => {
expect(boosted[0].hybrid_score).toBeGreaterThan(0.7);
});
+ it("keeps memory cards when committed-generation metadata lookup fails", async () => {
+ const cards = [
+ {
+ id: "card-new",
+ document_id: "doc-1",
+ owner_id: "user-1",
+ section_id: null,
+ card_type: "threshold",
+ title: "Lithium monitoring",
+ content: "Lithium monitoring threshold evidence.",
+ normalized_terms: ["lithium", "monitoring"],
+ page_number: 1,
+ source_chunk_ids: ["chunk-1"],
+ source_image_ids: [],
+ confidence: 0.8,
+ metadata: { index_generation_id: "replacement-generation" },
+ } satisfies DocumentMemoryCard,
+ ];
+ class QueryStub implements PromiseLike<{ data: unknown; error: { message: string } | null }> {
+ constructor(private readonly table: string) {}
+ select() {
+ return this;
+ }
+ textSearch() {
+ return this;
+ }
+ order() {
+ return this;
+ }
+ limit() {
+ return this;
+ }
+ eq() {
+ return this;
+ }
+ in() {
+ return this;
+ }
+ then(
+ onfulfilled?:
+ ((value: { data: unknown; error: { message: string } | null }) => TResult1 | PromiseLike) | null,
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null,
+ ): PromiseLike {
+ const value =
+ this.table === "document_memory_cards"
+ ? { data: cards, error: null }
+ : { data: null, error: { message: "metadata unavailable" } };
+ return Promise.resolve(value).then(onfulfilled, onrejected);
+ }
+ }
+ const supabase = {
+ from: vi.fn((table: string) => new QueryStub(table)),
+ };
+
+ const result = await fetchMemoryCardsForQuery({
+ supabase: supabase as never,
+ query: "lithium monitoring",
+ ownerId: "user-1",
+ matchCount: 8,
+ });
+
+ expect(result.map((card) => card.id)).toEqual(["card-new"]);
+ });
+
it("persists memory cards without leaking internal section indexes into inserts", async () => {
const insertedMemoryRows: Record[] = [];
const insertedIndexUnits: Record[] = [];
diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts
index a728ccc475..23ad547541 100644
--- a/tests/private-access-routes.test.ts
+++ b/tests/private-access-routes.test.ts
@@ -85,6 +85,16 @@ class QueryBuilder implements PromiseLike {
return this;
}
+ gte(column: string, value: unknown) {
+ this.call.filters.push({ column, value });
+ return this;
+ }
+
+ lte(column: string, value: unknown) {
+ this.call.filters.push({ column, value });
+ return this;
+ }
+
is(column: string, value: unknown) {
this.call.filters.push({ column, value });
return this;
@@ -458,10 +468,11 @@ describe("private document API access", () => {
storage_path: `${userId}/images/${imageId}.png`,
mime_type: "image/png",
caption: "Owned image",
+ metadata: { index_generation_id: "generation-a" },
});
}
if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) {
- return ok({ id: documentId });
+ return ok({ id: documentId, metadata: { index_generation_id: "generation-a" } });
}
return ok(null);
});
@@ -478,6 +489,33 @@ describe("private document API access", () => {
expect(client.storageMocks.createSignedUrl).toHaveBeenCalledWith(`${userId}/images/${imageId}.png`, 600);
});
+ it("rejects image signed URLs for uncommitted replacement generations", async () => {
+ const client = createSupabaseMock((call) => {
+ if (call.table === "document_images") {
+ return ok({
+ document_id: documentId,
+ storage_path: `${userId}/images/${imageId}.png`,
+ mime_type: "image/png",
+ caption: "Replacement image",
+ metadata: { index_generation_id: "generation-new" },
+ });
+ }
+ if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) {
+ return ok({ id: documentId, metadata: { index_generation_id: "generation-old" } });
+ }
+ return ok(null);
+ });
+ mockRuntime(client);
+ const { GET } = await import("../src/app/api/images/[id]/signed-url/route");
+
+ const response = await GET(authenticatedRequest(`/api/images/${imageId}/signed-url`), {
+ params: Promise.resolve({ id: imageId }),
+ });
+
+ expect(response.status).toBe(404);
+ expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled();
+ });
+
it("rejects image signed URLs when the parent document belongs to another user", async () => {
const client = createSupabaseMock((call) => {
if (call.table === "document_images") {
@@ -1289,6 +1327,161 @@ describe("private document API access", () => {
expect(client.calls).toHaveLength(1);
});
+ it("filters document detail rows to the committed index generation", async () => {
+ const committedGeneration = "11111111-1111-4111-8111-111111111111";
+ const replacementGeneration = "22222222-2222-4222-8222-222222222222";
+ const client = createSupabaseMock((call) => {
+ if (call.table === "documents" && call.operation === "select") {
+ return ok({
+ id: documentId,
+ owner_id: userId,
+ page_count: 1,
+ chunk_count: 1,
+ image_count: 1,
+ metadata: { index_generation_id: committedGeneration },
+ });
+ }
+ if (call.table === "document_pages") return ok([{ id: "page-1", page_number: 1, text: "Page", metadata: {} }]);
+ if (call.table === "document_images") {
+ return ok([
+ {
+ id: "image-old",
+ page_number: 1,
+ caption: "Old",
+ image_type: "clinical_table",
+ metadata: { index_generation_id: committedGeneration },
+ },
+ {
+ id: "image-new",
+ page_number: 1,
+ caption: "New",
+ image_type: "clinical_table",
+ metadata: { index_generation_id: replacementGeneration },
+ },
+ ]);
+ }
+ if (call.table === "document_chunks") {
+ return ok([
+ {
+ id: "chunk-old",
+ page_number: 1,
+ chunk_index: 0,
+ content: "Old",
+ image_ids: [],
+ metadata: { index_generation_id: committedGeneration },
+ },
+ {
+ id: "chunk-new",
+ page_number: 1,
+ chunk_index: 1,
+ content: "New",
+ image_ids: [],
+ metadata: { index_generation_id: replacementGeneration },
+ },
+ ]);
+ }
+ if (call.table === "document_table_facts") {
+ return ok([
+ { id: "fact-old", document_id: documentId, metadata: { index_generation_id: committedGeneration } },
+ { id: "fact-new", document_id: documentId, metadata: { index_generation_id: replacementGeneration } },
+ ]);
+ }
+ return ok([]);
+ });
+ mockRuntime(client);
+ const { GET } = await import("../src/app/api/documents/[id]/route");
+
+ const response = await GET(authenticatedRequest(`/api/documents/${documentId}`), {
+ params: Promise.resolve({ id: documentId }),
+ });
+ const body = (await payload(response)) as {
+ images: Array<{ id: string }>;
+ chunks: Array<{ id: string }>;
+ tableFacts: Array<{ id: string }>;
+ };
+
+ expect(response.status).toBe(200);
+ expect(body.images.map((image: { id: string }) => image.id)).toEqual(["image-old"]);
+ expect(body.chunks.map((chunk: { id: string }) => chunk.id)).toEqual(["chunk-old"]);
+ expect(body.tableFacts.map((fact: { id: string }) => fact.id)).toEqual(["fact-old"]);
+ });
+
+ it("filters direct document search fallback rows to the committed index generation", async () => {
+ const committedGeneration = "11111111-1111-4111-8111-111111111111";
+ const replacementGeneration = "22222222-2222-4222-8222-222222222222";
+ const client = createSupabaseMock((call) => {
+ if (call.table === "documents" && call.operation === "select") {
+ return ok({ id: documentId, metadata: { index_generation_id: committedGeneration } });
+ }
+ if (call.table === "document_chunks") {
+ return ok([
+ {
+ id: "chunk-old",
+ page_number: 1,
+ chunk_index: 0,
+ section_heading: "Committed",
+ content: "lithium monitoring committed row",
+ image_ids: [],
+ metadata: { index_generation_id: committedGeneration },
+ index_generation_id: committedGeneration,
+ },
+ {
+ id: "chunk-new",
+ page_number: 1,
+ chunk_index: 1,
+ section_heading: "Replacement",
+ content: "lithium monitoring replacement row",
+ image_ids: [],
+ metadata: { index_generation_id: replacementGeneration },
+ index_generation_id: replacementGeneration,
+ },
+ ]);
+ }
+ return ok([]);
+ });
+ client.rpc.mockImplementation(async (name: string) =>
+ name === "search_document_chunks" ? fail("missing rpc") : ok([]),
+ );
+ mockRuntime(client);
+ const { GET } = await import("../src/app/api/documents/[id]/search/route");
+
+ const response = await GET(authenticatedRequest(`/api/documents/${documentId}/search?q=lithium`), {
+ params: Promise.resolve({ id: documentId }),
+ });
+ const body = (await payload(response)) as { strategy: string; results: Array<{ id: string }> };
+
+ expect(response.status).toBe(200);
+ expect(body.strategy).toBe("portable_ilike_fallback");
+ expect(body.results.map((result: { id: string }) => result.id)).toEqual(["chunk-old"]);
+ });
+
+ it("filters table fact review rows to the committed index generation", async () => {
+ const committedGeneration = "11111111-1111-4111-8111-111111111111";
+ const replacementGeneration = "22222222-2222-4222-8222-222222222222";
+ const client = createSupabaseMock((call) => {
+ if (call.table === "documents" && call.operation === "select") {
+ return ok({ id: documentId, metadata: { index_generation_id: committedGeneration } });
+ }
+ if (call.table === "document_table_facts") {
+ return ok([
+ { id: "fact-old", document_id: documentId, metadata: { index_generation_id: committedGeneration } },
+ { id: "fact-new", document_id: documentId, metadata: { index_generation_id: replacementGeneration } },
+ ]);
+ }
+ return ok([]);
+ });
+ mockRuntime(client);
+ const { GET } = await import("../src/app/api/documents/[id]/table-facts/route");
+
+ const response = await GET(authenticatedRequest(`/api/documents/${documentId}/table-facts`), {
+ params: Promise.resolve({ id: documentId }),
+ });
+ const body = (await payload(response)) as { tableFacts: Array<{ id: string }> };
+
+ expect(response.status).toBe(200);
+ expect(body.tableFacts.map((fact: { id: string }) => fact.id)).toEqual(["fact-old"]);
+ });
+
it("rejects malformed document detail ids before Supabase uuid filters", async () => {
const client = createSupabaseMock();
mockRuntime(client);
diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts
index bd3f4d3066..1de2411fcc 100644
--- a/tests/supabase-schema.test.ts
+++ b/tests/supabase-schema.test.ts
@@ -136,6 +136,17 @@ describe("Supabase schema Data API grants", () => {
expect(schema).toContain("public.is_committed_artifact_generation(m.metadata, d.metadata)");
expect(schema).toContain("public.is_committed_artifact_generation(f.metadata, d.metadata)");
expect(schema).toContain("public.is_committed_artifact_generation(u.metadata, d.metadata)");
+ for (const sql of [schema, atomicReindexMigration]) {
+ expect(sql).toContain(
+ "revoke execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) from public, anon, authenticated",
+ );
+ expect(sql).toContain(
+ "revoke execute on function public.is_committed_document_generation(uuid, jsonb) from public, anon, authenticated",
+ );
+ expect(sql).toContain(
+ "revoke execute on function public.is_committed_artifact_generation(jsonb, jsonb) from public, anon, authenticated",
+ );
+ }
expect(schema).toContain(
"grant execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) to service_role",
);
diff --git a/tests/worker-visual-capture.test.ts b/tests/worker-visual-capture.test.ts
index 8779cda199..3b88e56c79 100644
--- a/tests/worker-visual-capture.test.ts
+++ b/tests/worker-visual-capture.test.ts
@@ -33,6 +33,15 @@ describe("worker visual capture hardening", () => {
expect(workerSource).toContain('indexing_v3_agent_status: "pending"');
expect(workerSource).toContain('"optional_index_write_issues"');
expect(workerSource).toContain("indexing_v3_agent_repair_reason: agentRepairReason");
+ expect(workerSource).toContain('indexing_v3_agent_status: "completed"');
+ });
+
+ it("keeps atomic reindex fallback rows and image uploads generation-scoped", () => {
+ expect(workerSource).toContain("await replacePageRows(args.documentId, args.pages)");
+ expect(workerSource).toContain("await deleteStaleIndexGenerationRows(args.documentId, args.indexGenerationId)");
+ expect(workerSource).toContain("async function deleteStaleIndexGenerationRows");
+ expect(workerSource).toContain("`${imagePrefix}/${indexGenerationId}/image-${index + 1}${ext}`");
+ expect(workerSource).toContain('indexing_v3_agent_repair_reason: "core_index_committed"');
});
it("uses the strict completion RPC when inline enrichment succeeds", () => {
diff --git a/worker/main.ts b/worker/main.ts
index e1abfc976d..20c494efba 100644
--- a/worker/main.ts
+++ b/worker/main.ts
@@ -401,12 +401,6 @@ async function commitDocumentIndexGeneration(args: {
if (!error) return;
if (!isMissingSchemaError(error)) throw supabaseStageError("commit_document_index_generation", error);
- const { error: deletePagesError } = await supabase
- .from("document_pages")
- .delete()
- .eq("document_id", args.documentId);
- if (deletePagesError) throw supabaseStageError("delete document_pages", deletePagesError);
-
await updateDocument(args.documentId, {
status: "indexed",
page_count: args.pageCount,
@@ -415,9 +409,9 @@ async function commitDocumentIndexGeneration(args: {
error_message: null,
metadata: sanitizeJsonbRecord(args.metadata),
});
-
- await insertPageRows(args.pages);
+ await replacePageRows(args.documentId, args.pages);
await upsertIndexQuality(args.quality);
+ await deleteStaleIndexGenerationRows(args.documentId, args.indexGenerationId);
}
function buildDocumentPageRows(documentId: string, extracted: ExtractedDocument) {
@@ -438,6 +432,47 @@ async function insertPageRows(pages: ReturnType) {
if (error) throw supabaseStageError("upsert document_pages", error);
}
+async function replacePageRows(documentId: string, pages: ReturnType) {
+ const { error: deleteError } = await supabase.from("document_pages").delete().eq("document_id", documentId);
+ if (deleteError) throw supabaseStageError("delete stale document_pages", deleteError);
+ await insertPageRows(pages);
+}
+
+async function deleteStaleIndexGenerationRows(documentId: string, indexGenerationId: string) {
+ const deleteDirectGenerationRows = async (table: string) => {
+ const stale = await supabase
+ .from(table)
+ .delete()
+ .eq("document_id", documentId)
+ .neq("index_generation_id", indexGenerationId);
+ if (stale.error) throw supabaseStageError(`delete stale ${table}`, stale.error);
+ const missing = await supabase.from(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) => {
+ const stale = await supabase
+ .from(table)
+ .delete()
+ .eq("document_id", documentId)
+ .neq("metadata->>index_generation_id", indexGenerationId);
+ if (stale.error) throw supabaseStageError(`delete stale ${table}`, stale.error);
+ const missing = await supabase
+ .from(table)
+ .delete()
+ .eq("document_id", documentId)
+ .is("metadata->>index_generation_id", null);
+ if (missing.error) throw supabaseStageError(`delete generationless ${table}`, missing.error);
+ };
+
+ await deleteDirectGenerationRows("document_chunks");
+ await deleteMetadataGenerationRows("document_images");
+ await deleteMetadataGenerationRows("document_table_facts");
+ await deleteMetadataGenerationRows("document_embedding_fields");
+ await deleteMetadataGenerationRows("document_index_units");
+ await deleteMetadataGenerationRows("document_memory_cards");
+ await deleteMetadataGenerationRows("document_sections");
+}
+
async function upsertIndexQuality(quality: ReturnType) {
const { error } = await supabase.from("document_index_quality").upsert(sanitizeJsonbRecord(quality), {
onConflict: "document_id",
@@ -921,7 +956,7 @@ async function uploadAndCaptionImages(
const imagePrefix = job.documents.owner_id
? `${job.documents.owner_id}/images/${job.document_id}`
: `local/${job.document_id}`;
- const imagePath = `${imagePrefix}/image-${index + 1}${ext}`;
+ const imagePath = `${imagePrefix}/${indexGenerationId}/image-${index + 1}${ext}`;
const upload = await supabase.storage
.from(env.SUPABASE_IMAGE_BUCKET)
.upload(imagePath, bytes, { contentType: image.mimeType, upsert: true });
@@ -1443,6 +1478,7 @@ async function processJob(job: JobRow) {
});
const indexedAt = new Date().toISOString();
+ const coreAgentMessage = "Core index committed; enrichment pending.";
const committedCoreMetadata = {
...(job.documents.metadata ?? {}),
indexed_at: indexedAt,
@@ -1461,6 +1497,10 @@ async function processJob(job: JobRow) {
index_quality_metrics: initialQuality.metrics,
optional_index_write_issues: optionalIndexWriteIssues,
embedding_model: env.OPENAI_EMBEDDING_MODEL,
+ indexing_v3_agent_status: "pending",
+ indexing_v3_agent_last_error: coreAgentMessage,
+ indexing_v3_agent_repair_reason: "core_index_committed",
+ indexing_v3_agent_updated_at: indexedAt,
...metrics,
};
await commitDocumentIndexGeneration({
@@ -1571,7 +1611,12 @@ async function processJob(job: JobRow) {
indexing_v3_agent_repair_reason: agentRepairReason,
indexing_v3_agent_updated_at: new Date().toISOString(),
}
- : {}),
+ : {
+ indexing_v3_agent_status: "completed",
+ indexing_v3_agent_last_error: null,
+ indexing_v3_agent_repair_reason: null,
+ indexing_v3_agent_updated_at: enrichmentUpdatedAt ?? new Date().toISOString(),
+ }),
embedding_model: env.OPENAI_EMBEDDING_MODEL,
...metrics,
};
From dda60a5014c84f8a3920129f07d5a571bfbe7510 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 28 Jun 2026 19:24:42 +0800
Subject: [PATCH 12/13] test: stabilize mobile upload stress coverage
---
tests/ui-stress.spec.ts | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts
index 5352df87cb..45436c111f 100644
--- a/tests/ui-stress.spec.ts
+++ b/tests/ui-stress.spec.ts
@@ -239,7 +239,12 @@ test.describe("Clinical KB long-content stress coverage", () => {
viewport.name === "mobile"
? page.getByRole("dialog", { name: "Upload and indexing" })
: page.locator("#sources");
- await expect(uploadSurface.getByText("24 indexed").first()).toBeVisible();
+ await expect(dailyActions).toBeHidden();
+ await expect(uploadSurface).toBeVisible();
+ await expect(uploadSurface.getByRole("button", { name: "Show indexed document files" })).toContainText(
+ "24 indexed",
+ { timeout: 20_000 },
+ );
const closeUploadSheet = page.getByRole("button", { name: "Close Upload and indexing" });
if (await closeUploadSheet.isVisible().catch(() => false)) {
await closeUploadSheet.click();
From 8418010ce135d9bb39c724da1da4d58c5069e9f1 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 28 Jun 2026 19:35:29 +0800
Subject: [PATCH 13/13] test: align stress upload mock with ready setup
---
tests/ui-stress.spec.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts
index 45436c111f..30b836e953 100644
--- a/tests/ui-stress.spec.ts
+++ b/tests/ui-stress.spec.ts
@@ -186,6 +186,7 @@ async function mockStressData(page: Page) {
detail: "Mocked Supabase project ready.",
},
{ id: "schema", label: "supabase/schema.sql applied", status: "ready", detail: "Mocked schema ready." },
+ { id: "search", label: "Search RPC and vector indexes", status: "ready", detail: "Mocked search ready." },
{ id: "openai", label: "OpenAI API key available", status: "ready", detail: "Mocked key ready." },
{ id: "worker", label: "npm run worker running", status: "ready", detail: "Mocked worker ready." },
],
@@ -238,7 +239,7 @@ test.describe("Clinical KB long-content stress coverage", () => {
const uploadSurface =
viewport.name === "mobile"
? page.getByRole("dialog", { name: "Upload and indexing" })
- : page.locator("#sources");
+ : page.locator("#dashboard-upload-drawer");
await expect(dailyActions).toBeHidden();
await expect(uploadSurface).toBeVisible();
await expect(uploadSurface.getByRole("button", { name: "Show indexed document files" })).toContainText(