onToggleScope(document.id)}
@@ -4521,6 +5216,7 @@ function SetupChecklist({ checks }: { checks: SetupCheck[] }) {
}
type LibraryHealthTarget = "documents" | "setup" | "indexing" | "failures";
+type DocumentDrawerMode = "recent" | "library" | "source" | "admin";
type DocumentDrawerStatusFilter = "all" | "indexed" | "indexing" | "failed";
type IndexingMonitorFilter = "all" | "active" | "failed";
type UploadIndexingTab = "setup" | "upload" | "jobs" | "quality";
@@ -4635,10 +5331,10 @@ function DrawerGroupLabel({ title }: { title: string }) {
}
const sidebarToolItems = [
- { label: "Formulation", icon: Brain },
- { label: "DSM-5", icon: BookOpen },
- { label: "Meds", icon: Pill },
- { label: "Diffs", icon: Search },
+ { label: "Formulation", icon: Brain, href: "/applications" },
+ { label: "DSM-5", icon: BookOpen, href: "/applications" },
+ { label: "Meds", icon: Pill, href: "/?mode=prescribing" },
+ { label: "Diffs", icon: Search, href: "/applications" },
] as const;
function ClinicalSidebarContent({
@@ -4646,6 +5342,7 @@ function ClinicalSidebarContent({
onNewChat,
onPickRecent,
onOpenGuide,
+ onPrefetchApplications,
showHeader = true,
onCollapsedChange,
onNavigate,
@@ -4654,6 +5351,7 @@ function ClinicalSidebarContent({
onNewChat: () => void;
onPickRecent: (query: string) => void;
onOpenGuide: () => void;
+ onPrefetchApplications?: () => void;
showHeader?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
onNavigate?: () => void;
@@ -4748,13 +5446,23 @@ function ClinicalSidebarContent({
-
Top tools
+
+ Applications
+
{sidebarToolItems.map((item) => {
const Icon = item.icon;
return (
-
+
{item.label}
@@ -4762,11 +5470,14 @@ function ClinicalSidebarContent({
})}
- View all tools
+ View applications
@@ -4811,6 +5522,7 @@ function ClinicalDesktopSidebar({
onNewChat,
onPickRecent,
onOpenGuide,
+ onPrefetchApplications,
}: {
collapsed: boolean;
recentQueries: string[];
@@ -4818,6 +5530,7 @@ function ClinicalDesktopSidebar({
onNewChat: () => void;
onPickRecent: (query: string) => void;
onOpenGuide: () => void;
+ onPrefetchApplications: () => void;
}) {
if (collapsed) {
return (
@@ -4840,7 +5553,15 @@ function ClinicalDesktopSidebar({
-
+
@@ -4868,6 +5589,7 @@ function ClinicalDesktopSidebar({
onNewChat={onNewChat}
onPickRecent={onPickRecent}
onOpenGuide={onOpenGuide}
+ onPrefetchApplications={onPrefetchApplications}
/>
);
@@ -4880,6 +5602,7 @@ function ClinicalMobileSidebar({
onNewChat,
onPickRecent,
onOpenGuide,
+ onPrefetchApplications,
}: {
open: boolean;
recentQueries: string[];
@@ -4887,6 +5610,7 @@ function ClinicalMobileSidebar({
onNewChat: () => void;
onPickRecent: (query: string) => void;
onOpenGuide: () => void;
+ onPrefetchApplications: () => void;
}) {
return (
onOpenChange(false)}
/>
);
}
-type MobileSectionFabItem = {
- label: string;
- description: string;
+type FavouriteType = "medications" | "documents" | "sources" | "sets";
+type FavouriteTabId = "all" | FavouriteType;
+
+type FavouriteItem = {
+ id: string;
+ title: string;
+ type: Exclude;
+ set: string;
+ meta: string;
+ sourceMeta: string;
+ primaryAction: string;
icon: typeof FileText;
- href: (typeof navigationHashes)[number];
+ keywords: string;
+};
+
+type FavouriteSet = {
+ id: string;
+ title: string;
+ count: number;
+ meta: string;
+ keywords: string;
+};
+
+const favouriteTabs: Array<{
+ id: FavouriteTabId;
+ label: string;
+ shortLabel: string;
+ icon: typeof FileText;
+}> = [
+ { id: "all", label: "All", shortLabel: "All", icon: LayoutList },
+ { id: "medications", label: "Medications", shortLabel: "Meds", icon: Pill },
+ { id: "documents", label: "Documents", shortLabel: "Docs", icon: FileText },
+ { id: "sources", label: "Sources", shortLabel: "Sources", icon: Quote },
+ { id: "sets", label: "Sets", shortLabel: "Sets", icon: Folder },
+];
+
+const favouriteItems: FavouriteItem[] = [
+ {
+ id: "acamprosate-renal-screen",
+ title: "Acamprosate renal screen",
+ type: "medications",
+ set: "Ward round",
+ meta: "Medication page · renal cautions · dose notes",
+ sourceMeta: "3 sources",
+ primaryAction: "Open",
+ icon: Pill,
+ keywords: "acamprosate renal screen medication dose safety ward round pbs",
+ },
+ {
+ id: "lithium-monitoring-guideline",
+ title: "Lithium monitoring guideline",
+ type: "documents",
+ set: "Prescribing safety",
+ meta: "PDF · p.4-9 · 2 tables",
+ sourceMeta: "PDF",
+ primaryAction: "Ask",
+ icon: FileText,
+ keywords: "lithium monitoring guideline blood tests shared care renal toxicity",
+ },
+ {
+ id: "clozapine-monitoring-table",
+ title: "Clozapine monitoring table",
+ type: "sources",
+ set: "Clozapine clinic",
+ meta: "Saved table · ANC monitoring",
+ sourceMeta: "Table",
+ primaryAction: "Source",
+ icon: Quote,
+ keywords: "clozapine monitoring table anc fbc neutrophil clinic",
+ },
+ {
+ id: "renal-dose-search",
+ title: "renal dose saved search",
+ type: "sources",
+ set: "Ward round",
+ meta: "Saved query · medicines + documents",
+ sourceMeta: "Run",
+ primaryAction: "Run",
+ icon: Search,
+ keywords: "renal dose saved search kidney egfr medicines documents",
+ },
+ {
+ id: "qt-prolongation-quote",
+ title: "QT prolongation quote",
+ type: "sources",
+ set: "Prescribing safety",
+ meta: "Source card · prescribing safety",
+ sourceMeta: "Quote",
+ primaryAction: "Copy",
+ icon: Quote,
+ keywords: "qt prolongation quote source card prescribing safety",
+ },
+];
+
+const favouriteSets: FavouriteSet[] = [
+ {
+ id: "ward-round",
+ title: "Ward round",
+ count: 12,
+ meta: "Medication pages, renal checks, forms",
+ keywords: "ward round acamprosate lithium renal mht forms",
+ },
+ {
+ id: "prescribing-safety",
+ title: "Prescribing safety",
+ count: 9,
+ meta: "Dose limits, pregnancy, renal cautions",
+ keywords: "prescribing safety dose pregnancy renal qt interactions",
+ },
+ {
+ id: "clozapine-clinic",
+ title: "Clozapine clinic",
+ count: 7,
+ meta: "Monitoring, ANC table, counselling",
+ keywords: "clozapine clinic monitoring anc table counselling",
+ },
+];
+
+function favouriteTypeCount(type: FavouriteTabId) {
+ if (type === "all") return favouriteItems.length + favouriteSets.length;
+ if (type === "sets") return favouriteSets.length;
+ return favouriteItems.filter((item) => item.type === type).length;
+}
+
+function favouriteMatchesQuery(value: { title: string; meta?: string; set?: string; keywords: string }, query: string) {
+ const normalized = query.trim().toLowerCase();
+ if (!normalized) return true;
+ return [value.title, value.meta, value.set, value.keywords]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase()
+ .includes(normalized);
+}
+
+function FavouritesHub({
+ query,
+ onClearQuery,
+ onAddFavourite,
+}: {
+ query: string;
+ onClearQuery: () => void;
+ onAddFavourite: () => void;
+}) {
+ const [selectedTab, setSelectedTab] = useState("all");
+ const [tabMenuOpen, setTabMenuOpen] = useState(false);
+ const [selectedSetId, setSelectedSetId] = useState(null);
+ const tabMenuRef = useRef(null);
+ const normalizedQuery = query.trim();
+ const selectedSet = selectedSetId ? favouriteSets.find((set) => set.id === selectedSetId) : null;
+ const tabItems =
+ selectedTab === "all" || selectedTab === "sets"
+ ? favouriteItems
+ : favouriteItems.filter((item) => item.type === selectedTab);
+ const visibleItems = tabItems
+ .filter((item) => favouriteMatchesQuery(item, normalizedQuery))
+ .filter((item) => !selectedSet || item.set === selectedSet.title);
+ const visibleSets = favouriteSets.filter((set) => favouriteMatchesQuery(set, normalizedQuery));
+ const showSets = selectedTab === "all" || selectedTab === "sets";
+ const showItems = selectedTab !== "sets";
+ const empty = (!showItems || visibleItems.length === 0) && (!showSets || visibleSets.length === 0);
+ const selectedTabMeta = favouriteTabs.find((tab) => tab.id === selectedTab) ?? favouriteTabs[0];
+ const selectedTabLabel = selectedTabMeta.label;
+ const selectedTabCount = favouriteTypeCount(selectedTab);
+ const SelectedTabIcon = selectedTabMeta.icon;
+ const itemCount = favouriteItems.length;
+ const setCount = favouriteSets.length;
+ const activeFilterCount = (normalizedQuery ? 1 : 0) + (selectedSet ? 1 : 0);
+
+ useEffect(() => {
+ if (!tabMenuOpen) return undefined;
+
+ function handlePointerDown(event: PointerEvent) {
+ const target = event.target;
+ if (!(target instanceof Node)) return;
+ if (!tabMenuRef.current?.contains(target)) setTabMenuOpen(false);
+ }
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ setTabMenuOpen(false);
+ }
+ }
+
+ document.addEventListener("pointerdown", handlePointerDown);
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("pointerdown", handlePointerDown);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [tabMenuOpen]);
+
+ return (
+
+
+
+
+
+
+
+
+ Favourites
+
+
+ Keep trusted notes, sources, medication pages, and clinical sets ready for reuse.
+
+
+
+
+
+ {[
+ { label: "Items", value: itemCount, icon: Heart },
+ { label: "Sets", value: setCount, icon: Folder },
+ { label: "Active", value: activeFilterCount, icon: Filter },
+ ].map((stat) => {
+ const Icon = stat.icon;
+ return (
+
+
+
+ {stat.label}
+
+
+ {stat.value}
+
+
+ );
+ })}
+
+
+
+
+
+
+
setTabMenuOpen((open) => !open)}
+ className="grid min-h-11 w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] px-2.5 text-left shadow-[var(--shadow-tight)] transition hover:border-[color:var(--clinical-chat-teal)]/30 hover:bg-[color:var(--surface-raised)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:w-56"
+ aria-haspopup="listbox"
+ aria-expanded={tabMenuOpen}
+ aria-label="Choose favourite type"
+ >
+
+
+
+
+
+ View
+
+
+ {selectedTabLabel} · {selectedTabCount}
+
+
+
+
+
+ {tabMenuOpen ? (
+
+ {favouriteTabs.map((tab) => {
+ const Icon = tab.icon;
+ const selected = selectedTab === tab.id;
+ const count = favouriteTypeCount(tab.id);
+ return (
+ {
+ setSelectedTab(tab.id);
+ setTabMenuOpen(false);
+ }}
+ className={cn(
+ "grid min-h-10 grid-cols-[1.75rem_minmax(0,1fr)_auto] items-center gap-2 rounded-lg px-2.5 text-left text-sm transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
+ selected
+ ? "bg-[color:var(--clinical-chat-teal-soft)] text-[color:var(--clinical-chat-teal)] shadow-[var(--shadow-inset)]"
+ : "text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]",
+ )}
+ >
+
+
+
+ {tab.label}
+
+ {count}
+
+
+ );
+ })}
+
+ ) : null}
+
+ {normalizedQuery ? (
+
+ Filter: {normalizedQuery}
+
+
+ ) : null}
+ {selectedSet ? (
+
setSelectedSetId(null)}
+ className="inline-flex min-h-7 max-w-full items-center gap-1.5 rounded-md border border-[color:var(--clinical-chat-teal)]/20 bg-[color:var(--clinical-chat-teal-soft)] px-2 font-bold text-[color:var(--clinical-chat-teal)] hover:border-[color:var(--clinical-chat-teal)]/35"
+ >
+ Set: {selectedSet.title}
+
+
+ ) : null}
+
+
+
+
+ Recent
+
+
+
+ Add favourite
+ Add
+
+
+
+
+
+
+
Saved sets
+
setSelectedTab("sets")}
+ className="text-xs font-bold text-[color:var(--clinical-chat-teal)]"
+ >
+ View all
+
+
+
+ {visibleSets.slice(0, 3).map((set) => (
+ {
+ setSelectedSetId(set.id);
+ if (selectedTab === "sets") setSelectedTab("all");
+ }}
+ />
+ ))}
+
+
+
+
+
+
+
+
+ {selectedTab === "all"
+ ? "Recent favourites"
+ : selectedTab === "sets"
+ ? "Saved sets"
+ : `${selectedTabLabel} favourites`}
+
+
+ {selectedTab === "sets" ? "Open a focused clinical set." : "Open, ask, copy, or organise saved items."}
+
+
+
+ {selectedTab === "sets" ? visibleSets.length : visibleItems.length}
+
+
+
+
+ {showSets && selectedTab === "sets"
+ ? visibleSets.map((set) => (
+
{
+ setSelectedSetId(set.id);
+ setSelectedTab("all");
+ }}
+ />
+ ))
+ : null}
+
+ {showItems
+ ? visibleItems.map((item) => (
+ setSelectedTab("sets")} />
+ ))
+ : null}
+
+ {empty ? (
+
+
+
+
No favourites match
+
+ Clear the composer text or choose another tab.
+
+
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
+
+function FavouriteItemRow({ item, onMoveToSet }: { item: FavouriteItem; onMoveToSet: () => void }) {
+ const Icon = item.icon;
+ return (
+
+
+
+
+
+
{item.title}
+
{item.meta}
+
+ {item.set}
+
+ {item.sourceMeta}
+
+
+
+
+
+ {item.primaryAction}
+
+
+
+ Move
+
+
+
+
+
+
+ );
+}
+
+function FavouriteSetRow({
+ favouriteSet,
+ compact = false,
+ selected = false,
+ onSelect,
+}: {
+ favouriteSet: FavouriteSet;
+ compact?: boolean;
+ selected?: boolean;
+ onSelect: () => void;
+}) {
+ return (
+
+
+
+
+
+
+ {favouriteSet.title}
+
+
+ {favouriteSet.count} items{compact ? "" : ` · ${favouriteSet.meta}`}
+
+
+
+
+ );
+}
+
+function toolMatchesQuery(tool: ToolItem, query: string) {
+ const normalized = query.trim().toLowerCase();
+ if (!normalized) return true;
+ return [tool.title, tool.description, tool.category, tool.status, tool.id]
+ .join(" ")
+ .toLowerCase()
+ .includes(normalized);
+}
+
+function toolStatusLabel(tool: ToolItem) {
+ if (tool.status === "coming-soon") return "Soon";
+ if (tool.status === "offline") return "Paused";
+ if (tool.status === "beta") return "Preview";
+ return "Ready";
+}
+
+const TOOL_ICON_MAP: Record> = {
+ Brain,
+ ClipboardList,
+ Search,
+ FileImage,
+ FileText,
+ HeartHandshake,
+ Network,
+ Pill,
+ UploadCloud,
+ BookOpen,
+ Quote,
+ ShieldAlert,
+ Target,
+ ClipboardCheck,
+ ListChecks,
+ Sparkles,
+ Clipboard: ClipboardCheck,
+ ExternalLink,
+};
+
+function getToolIcon(iconName: string) {
+ return TOOL_ICON_MAP[iconName] || Wrench;
+}
+
+function cleanToolHrefLabel(href: string) {
+ try {
+ if (href.startsWith("/")) return href;
+ const url = new URL(href);
+ return url.host;
+ } catch {
+ return href;
+ }
+}
+
+function ToolsHub({ query, onClearQuery }: { query: string; onClearQuery: () => void }) {
+ const normalizedQuery = query.trim();
+ const visibleTools = toolCatalog.filter((tool) => toolMatchesQuery(tool, normalizedQuery));
+ const onlineTools = toolCatalog.filter((tool) => tool.status === "online" || tool.status === "beta").length;
+ const internalTools = toolCatalog.filter((tool) => tool.target === "internal").length;
+ const activeFilterCount = normalizedQuery ? 1 : 0;
+
+ return (
+
+
+
+
+
+
+
+
Tools
+
+ Search the existing clinical applications registry and launch the right workflow without leaving the
+ dashboard shell.
+
+
+
+
+
+ {[
+ { label: "Tools", value: toolCatalog.length, icon: Wrench },
+ { label: "Ready", value: onlineTools, icon: CheckCircle2 },
+ { label: "In-app", value: internalTools, icon: LayoutList },
+ ].map((stat) => {
+ const Icon = stat.icon;
+ return (
+
+
+
+ {stat.label}
+
+
+ {stat.value}
+
+
+ );
+ })}
+
+
+
+
+
+ Showing {visibleTools.length} of {toolCatalog.length} tools
+
+
+ {activeFilterCount > 0 && (
+
+
+ Clear search
+
+ )}
+
+
+ Full launcher
+
+
+
+
+ {visibleTools.length === 0 ? (
+
+ ) : (
+
+ {visibleTools.map((tool) => {
+ const disabled = tool.status === "offline" || tool.status === "coming-soon";
+ const launchLabel = `Launch ${tool.title}`;
+ const launchClassName = cn(
+ "inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-3 text-sm font-bold shadow-[var(--shadow-inset)] transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
+ disabled
+ ? "border border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-soft)]"
+ : "bg-[color:var(--clinical-chat-teal)] text-white hover:bg-[color:var(--clinical-chat-teal-strong)]",
+ );
+ const ToolIcon = getToolIcon(tool.icon);
+ const isOnline = tool.status === "online" || tool.status === "beta";
+ const statusColorClass =
+ tool.status === "online"
+ ? "bg-emerald-500 shadow-[0_0_8px_#10b981]"
+ : tool.status === "beta"
+ ? "bg-amber-500 shadow-[0_0_8px_#f59e0b]"
+ : "bg-slate-400";
+
+ return (
+
+
+
+
+
+
+ {toolStatusLabel(tool)}
+
+
+
+
+
{tool.title}
+
+ {tool.description}
+
+
+ {tool.category}
+
+ {tool.target === "internal" ? "In-app" : "Connected"}
+
+
+
+
+
+ {cleanToolHrefLabel(tool.href)}
+
+
+
+
+ {disabled ? (
+ {tool.disabledHint ?? "Unavailable"}
+ ) : tool.target === "internal" ? (
+
+ Launch
+
+
+ ) : (
+
+ Launch
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+type MobileSectionFabItem = {
+ label: string;
+ description: string;
+ icon: typeof FileText;
+ href: (typeof navigationHashes)[number];
count: number | null;
empty?: boolean;
};
@@ -4956,18 +6452,37 @@ function buildMobileSectionFabState({
governanceWarningCount,
}: {
hasAnswer: boolean;
- searchMode: SearchMode;
+ searchMode: AppModeId;
sourceCount: number;
quoteCount: number;
weakEvidence: boolean;
governanceWarningCount: number;
}): MobileSectionFabState {
+ const modeSearch = appModeSearchConfig(searchMode);
if (!hasAnswer) {
+ if (modeSearch.resultKind === "favourites") {
+ return {
+ statusLabel: "Favourites",
+ statusTone: "neutral",
+ nextStep: "Browse saved items",
+ badgeLabel: null,
+ badgeTone: "neutral",
+ };
+ }
+ if (modeSearch.resultKind === "tools") {
+ return {
+ statusLabel: "Tools",
+ statusTone: "neutral",
+ nextStep: "Launch a clinical tool",
+ badgeLabel: null,
+ badgeTone: "neutral",
+ };
+ }
return {
- statusLabel: searchMode === "documents" ? "Document search" : "No answer yet",
+ statusLabel: modeSearch.resultKind === "documents" ? "Document search" : "No answer yet",
statusTone: "empty",
- nextStep: searchMode === "documents" ? "Review matching documents" : "Ask a question first",
- badgeLabel: searchMode === "documents" ? null : "?",
+ nextStep: modeSearch.nextStep,
+ badgeLabel: modeSearch.badgeLabel,
badgeTone: "empty",
};
}
@@ -5336,15 +6851,23 @@ function mergeDocumentRefresh(current: ClinicalDocument[], updates: ClinicalDocu
});
}
-export function ClinicalDashboard() {
+export function ClinicalDashboard({
+ initialSearchMode = "answer",
+ initialQuery = "",
+ focusSearch = false,
+}: { initialSearchMode?: AppModeId; initialQuery?: string; focusSearch?: boolean } = {}) {
+ const router = useRouter();
const mainRef = useRef(null);
+ const composerInputRef = useRef(null);
const scrollFrameRef = useRef(null);
const navSyncLockRef = useRef(null);
const refreshInFlightRef = useRef | null>(null);
const nextWorkStatePollRef = useRef(0);
const urlSearchBootstrappedRef = useRef(false);
+ const urlDocumentSearchBootstrappedRef = useRef(false);
const [documents, setDocuments] = useState([]);
const [documentsPagination, setDocumentsPagination] = useState(null);
+ const indexedDocumentTotal = documentsPagination?.total ?? documents.length;
const [dashboardDataLoading, setDashboardDataLoading] = useState(true);
const [loadingMoreDocuments, setLoadingMoreDocuments] = useState(false);
const [jobs, setJobs] = useState([]);
@@ -5352,14 +6875,17 @@ export function ClinicalDashboard() {
const [qualityItems, setQualityItems] = useState([]);
const jobsRef = useRef(jobs);
const batchesRef = useRef(batches);
- const [query, setQuery] = useState("");
- const [searchMode, setSearchMode] = useState("answer");
+ const [query, setQuery] = useState(initialQuery);
+ const [searchMode, setSearchMode] = useState(initialSearchMode);
const [answer, setAnswer] = useState(null);
const [sources, setSources] = useState([]);
const [documentMatches, setDocumentMatches] = useState([]);
const [searchRelevance, setSearchRelevance] = useState(null);
const [searchFacets, setSearchFacets] = useState(null);
const [queryMode, setQueryMode] = useState("auto");
+ const activeModeSearch = appModeSearchConfig(searchMode);
+ const activeModeResultKind = appModeResultKind(searchMode);
+ const requestQueryMode = appModeQueryMode(searchMode, queryMode);
const [scopeFilters, setScopeFilters] = useState({});
const [searchScope, setSearchScope] = useState(null);
const [sourceGovernanceWarnings, setSourceGovernanceWarnings] = useState([]);
@@ -5384,6 +6910,7 @@ export function ClinicalDashboard() {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [documentsDrawerOpen, setDocumentsDrawerOpen] = useState(false);
+ const [documentsDrawerMode, setDocumentsDrawerMode] = useState("library");
const [uploadDrawerOpen, setUploadDrawerOpen] = useState(false);
const [uploadMobileTab, setUploadMobileTab] = useState("upload");
const [documentDrawerStatusFilter, setDocumentDrawerStatusFilter] = useState("indexed");
@@ -5405,12 +6932,20 @@ export function ClinicalDashboard() {
const storedSessionExists =
typeof window !== "undefined" &&
Object.keys(localStorage).some((k) => k.startsWith("sb-") && k.endsWith("-auth-token"));
+ const localDevCanAttemptPrivateApis = process.env.NODE_ENV !== "production" && hasReadyPublicSearchSetup(setupChecks);
const canUsePrivateApis =
localProjectReady &&
- (localNoAuthMode || authStatus === "authenticated" || (supabaseEnvStatus === "ready" && storedSessionExists));
+ (localNoAuthMode ||
+ localDevCanAttemptPrivateApis ||
+ authStatus === "authenticated" ||
+ (supabaseEnvStatus === "ready" && storedSessionExists));
const canRunSearch = explicitDemoMode || (hasReadyPublicSearchSetup(setupChecks) && canUsePrivateApis);
const openGuide = useCallback(() => setGuideOpen(true), []);
const closeGuide = useCallback(() => setGuideOpen(false), []);
+ const prefetchApplications = useCallback(() => {
+ router.prefetch("/applications");
+ void import("@/components/applications-launcher-page");
+ }, [router]);
const openLibraryHealthTarget = useCallback((target: LibraryHealthTarget) => {
const targetId =
target === "documents"
@@ -5421,6 +6956,7 @@ export function ClinicalDashboard() {
if (target === "documents") {
setDocumentDrawerStatusFilter("indexed");
+ setDocumentsDrawerMode("admin");
setDocumentsDrawerOpen(true);
} else if (target === "indexing") {
setUploadMobileTab("jobs");
@@ -5441,6 +6977,11 @@ export function ClinicalDashboard() {
}, 0);
}, []);
+ useEffect(() => {
+ const timeoutId = window.setTimeout(prefetchApplications, 250);
+ return () => window.clearTimeout(timeoutId);
+ }, [prefetchApplications]);
+
useEffect(() => {
let cancelled = false;
const frame = window.requestAnimationFrame(() => {
@@ -5897,16 +7438,43 @@ export function ClinicalDashboard() {
}, []);
useEffect(() => {
- if (urlSearchBootstrappedRef.current || !canRunSearch) return;
+ if (!focusSearch) return undefined;
+ focusComposerInput();
+ const timeout = window.setTimeout(focusComposerInput, 500);
+ return () => window.clearTimeout(timeout);
+ }, [focusSearch]);
+
+ useEffect(() => {
+ if (urlSearchBootstrappedRef.current) return;
const params = new URLSearchParams(window.location.search);
const mode = params.get("mode");
const searchText = params.get("q")?.trim();
- if (mode !== "documents") return;
+ const shouldFocusComposer = params.get("focus") === "1";
+ if (!isAppModeId(mode) || !isAppModeVisible(mode)) return;
urlSearchBootstrappedRef.current = true;
- const frame = window.requestAnimationFrame(() => setSearchMode("documents"));
- if (searchText) void runDocumentSearchShortcut(searchText, scopeFilters, false);
+ const targetMode = mode;
+ const frame = window.requestAnimationFrame(() => {
+ setSearchMode(targetMode);
+ if (searchText) setQuery(searchText);
+ if (shouldFocusComposer) focusComposerInput();
+ });
return () => window.cancelAnimationFrame(frame);
- // URL bootstrap intentionally runs once when search setup becomes available.
+ }, []);
+
+ useEffect(() => {
+ if (urlDocumentSearchBootstrappedRef.current) return;
+ const params = new URLSearchParams(window.location.search);
+ const mode = params.get("mode");
+ const searchText = params.get("q")?.trim();
+ if (!searchText || !isAppModeId(mode) || !isAppModeVisible(mode)) return;
+ if (mode === "prescribing") return;
+ const modeSearch = appModeSearchConfig(mode);
+ const shouldRun = params.get("run") === "1" || modeSearch.kind === "documents";
+ if (!shouldRun) return;
+ if (modeSearch.kind !== "favourites" && modeSearch.kind !== "tools" && !canRunSearch) return;
+ urlDocumentSearchBootstrappedRef.current = true;
+ void executeSearch(searchText, mode, scopeFilters);
+ // URL search intentionally runs once when the selected mode can execute.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canRunSearch]);
@@ -5943,7 +7511,11 @@ export function ClinicalDashboard() {
);
}
- async function requestDocuments(queryText: string, filtersOverride?: SearchScopeFilters) {
+ async function requestDocuments(
+ queryText: string,
+ filtersOverride?: SearchScopeFilters,
+ queryModeOverride: ClinicalQueryMode = requestQueryMode,
+ ) {
let response: Response;
try {
response = await fetch("/api/search", {
@@ -5957,7 +7529,7 @@ export function ClinicalDashboard() {
mode: "documents",
documentIds: selectedDocumentIds.length > 0 ? selectedDocumentIds : undefined,
filters: compactScopeFilters(filtersOverride ?? scopeFilters),
- queryMode,
+ queryMode: queryModeOverride,
documentLimit: 30,
topK: 20,
}),
@@ -5991,7 +7563,11 @@ export function ClinicalDashboard() {
};
}
- async function requestAnswer(queryText: string) {
+ async function requestAnswer(
+ queryText: string,
+ filtersOverride: SearchScopeFilters = scopeFilters,
+ queryModeOverride: ClinicalQueryMode = requestQueryMode,
+ ) {
let response: Response;
try {
response = await fetch("/api/answer/stream", {
@@ -6003,8 +7579,8 @@ export function ClinicalDashboard() {
body: JSON.stringify({
query: queryText,
documentIds: selectedDocumentIds.length > 0 ? selectedDocumentIds : undefined,
- filters: compactScopeFilters(scopeFilters),
- queryMode,
+ filters: compactScopeFilters(filtersOverride),
+ queryMode: queryModeOverride,
}),
});
} catch {
@@ -6089,14 +7665,31 @@ export function ClinicalDashboard() {
if (answerData.demoMode) setDemoMode(true);
}
- async function ask() {
- const trimmedQuery = query.trim();
+ async function executeSearch(searchText: string, targetMode: AppModeId = searchMode, filtersOverride = scopeFilters) {
+ const trimmedQuery = searchText.trim();
if (!trimmedQuery) return;
+ const modeSearch = appModeSearchConfig(targetMode);
+ const targetQueryMode = appModeQueryMode(targetMode, queryMode);
+
+ setSearchMode(targetMode);
+ setQuery(trimmedQuery);
+
+ if (modeSearch.kind === "favourites") {
+ setError(null);
+ rememberRecentQuery(trimmedQuery);
+ setActionNotice({ tone: "success", message: "Favourites filtered from the composer." });
+ return;
+ }
+ if (modeSearch.kind === "tools") {
+ setError(null);
+ rememberRecentQuery(trimmedQuery);
+ setActionNotice({ tone: "success", message: "Tools filtered from the composer." });
+ return;
+ }
if (!canRunSearch) {
setError("Search setup not ready.");
return;
}
-
setLoading(true);
setError(null);
setSearchRelevance(null);
@@ -6104,7 +7697,7 @@ export function ClinicalDashboard() {
setSearchScope(null);
setSourceGovernanceWarnings([]);
setAnswerViewMode("high_yield");
- setAnswerProgress(searchMode === "documents" ? "Finding matching documents." : "Searching indexed documents.");
+ setAnswerProgress(modeSearch.progressLabel);
rememberRecentQuery(trimmedQuery);
const fallbackQuery = keywordQueryFromNaturalLanguage(trimmedQuery);
@@ -6125,9 +7718,9 @@ export function ClinicalDashboard() {
try {
const payload =
- searchMode === "documents"
- ? await runWithRetries(() => requestDocuments(entry.query))
- : await runWithRetries(() => requestAnswer(entry.query));
+ modeSearch.kind === "documents"
+ ? await runWithRetries(() => requestDocuments(entry.query, filtersOverride, targetQueryMode))
+ : await runWithRetries(() => requestAnswer(entry.query, filtersOverride, targetQueryMode));
if (!resultUsable(payload)) {
lastError = makeSearchError("No usable results were found.", 404, false);
@@ -6162,6 +7755,35 @@ export function ClinicalDashboard() {
}
}
+ function setMedicationSearchQuery(searchText: string, updateUrl = true) {
+ const trimmedSearchText = searchText.trim();
+ if (!trimmedSearchText) return;
+ setSearchMode("prescribing");
+ setQuery(trimmedSearchText);
+ setLoading(false);
+ setError(null);
+ setAnswerProgress(null);
+ rememberRecentQuery(trimmedSearchText);
+ window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" }));
+ if (updateUrl) router.replace(appModeHomeHref("prescribing", { query: trimmedSearchText }));
+ }
+
+ async function ask() {
+ if (searchMode === "prescribing") {
+ setMedicationSearchQuery(query);
+ return;
+ }
+ await executeSearch(query, searchMode, scopeFilters);
+ }
+
+ function pickRecentQuery(recentQuery: string) {
+ if (searchMode === "prescribing") {
+ setMedicationSearchQuery(recentQuery);
+ return;
+ }
+ setQuery(recentQuery);
+ }
+
async function submitAnswerFeedback(feedbackType: AnswerFeedbackType) {
if (!answer || pendingFeedback) return;
if (clientDemoMode) {
@@ -6243,14 +7865,16 @@ export function ClinicalDashboard() {
window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" }));
}
- function updateDocumentSearchUrl(searchText: string) {
- const params = new URLSearchParams(window.location.search);
- params.set("mode", "documents");
- params.set("q", searchText);
- window.history.replaceState(null, "", `/?${params.toString()}`);
+ function updateDocumentSearchUrl(searchText: string, mode: AppModeId = "documents") {
+ window.history.replaceState(null, "", appModeHomeHref(mode, { query: searchText }));
}
- async function runDocumentSearchShortcut(searchText: string, filtersOverride = scopeFilters, updateUrl = true) {
+ async function runDocumentSearchShortcut(
+ searchText: string,
+ filtersOverride = scopeFilters,
+ updateUrl = true,
+ targetMode: AppModeId = "documents",
+ ) {
const trimmedSearchText = searchText.trim();
if (!trimmedSearchText) return;
if (!canRunSearch) {
@@ -6259,10 +7883,10 @@ export function ClinicalDashboard() {
}
setQuery(trimmedSearchText);
- setSearchMode("documents");
+ setSearchMode(targetMode);
setLoading(true);
setError(null);
- setAnswerProgress("Finding matching documents.");
+ setAnswerProgress(appModeSearchConfig(targetMode).progressLabel);
setSearchRelevance(null);
setSearchFacets(null);
setSearchScope(null);
@@ -6270,10 +7894,13 @@ export function ClinicalDashboard() {
setAnswerViewMode("high_yield");
rememberRecentQuery(trimmedSearchText);
window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" }));
- if (updateUrl) updateDocumentSearchUrl(trimmedSearchText);
+ if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode);
try {
- const payload = await runWithRetries(() => requestDocuments(trimmedSearchText, filtersOverride));
+ const shortcutQueryMode = appModeQueryMode(targetMode, queryMode);
+ const payload = await runWithRetries(() =>
+ requestDocuments(trimmedSearchText, filtersOverride, shortcutQueryMode),
+ );
applySearchResult(payload);
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Document search failed");
@@ -6356,8 +7983,25 @@ export function ClinicalDashboard() {
}
}
+ function selectSearchMode(mode: AppModeId) {
+ setSearchMode(mode);
+ router.replace(appModeHomeHref(mode));
+ }
+
+ function focusComposerInput() {
+ window.requestAnimationFrame(() => {
+ composerInputRef.current?.focus({ preventScroll: true });
+ window.setTimeout(() => composerInputRef.current?.focus({ preventScroll: true }), 150);
+ });
+ }
+
function startNewChat() {
+ const href = appModeHomeHref("answer", { focus: true });
setQuery("");
+ setSearchMode("answer");
+ setQueryMode("auto");
+ setSelectedDocumentIds([]);
+ setScopeFilters({});
setAnswer(null);
setSources([]);
setDocumentMatches([]);
@@ -6368,11 +8012,46 @@ export function ClinicalDashboard() {
setError(null);
setAnswerProgress(null);
setAnswerViewMode("high_yield");
- window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" }));
+ router.replace(href);
+ window.requestAnimationFrame(() => {
+ mainRef.current?.scrollTo({ top: 0, behavior: "smooth" });
+ });
+ focusComposerInput();
+ }
+
+ function openDocumentsDrawer(mode: DocumentDrawerMode) {
+ setSearchMode("documents");
+ setDocumentDrawerStatusFilter("indexed");
+ setDocumentsDrawerMode(mode);
+ setDocumentsDrawerOpen(true);
+ window.requestAnimationFrame(() => {
+ document.getElementById("dashboard-documents-drawer")?.scrollIntoView({ block: "start", behavior: "smooth" });
+ });
+ }
+
+ function openRecentDocuments() {
+ openDocumentsDrawer("recent");
+ }
+
+ function openSourceLibrary() {
+ openDocumentsDrawer("library");
+ }
+
+ function openSourcePdfBrowser() {
+ openDocumentsDrawer("source");
}
function openUploadDrawer() {
+ if (!canUsePrivateApis) {
+ openDocumentsDrawer("library");
+ setActionNotice({
+ tone: "warning",
+ message: "Upload and indexing tools are admin-only. Use the source library to open indexed documents.",
+ });
+ return;
+ }
setSearchMode("documents");
+ setDocumentsDrawerMode("admin");
setUploadDrawerOpen(true);
window.requestAnimationFrame(() => {
const drawer = document.getElementById("dashboard-upload-drawer") as HTMLDetailsElement | null;
@@ -6560,21 +8239,43 @@ export function ClinicalDashboard() {
);
const bottomNavItems = [
{
- label: searchMode === "answer" ? "Answer" : "Docs",
+ label: activeModeSearch.statusLabel,
description:
- searchMode === "answer"
- ? answer
- ? weakEvidence
- ? "Read synthesis carefully"
- : "Clinical synthesis"
- : "Ask a question first"
- : documentMatches.length
- ? "Document results"
- : "Search documents",
- icon: searchMode === "answer" ? Search : FileText,
+ activeModeResultKind === "favourites"
+ ? query.trim()
+ ? "Filtered favourites"
+ : "Browse saved items"
+ : activeModeResultKind === "tools"
+ ? query.trim()
+ ? "Filtered tools"
+ : "Browse tools"
+ : activeModeResultKind === "answer"
+ ? answer
+ ? weakEvidence
+ ? "Read synthesis carefully"
+ : "Clinical synthesis"
+ : activeModeSearch.nextStep
+ : documentMatches.length
+ ? "Document results"
+ : activeModeSearch.readyTitle,
+ icon:
+ activeModeResultKind === "favourites"
+ ? Heart
+ : activeModeResultKind === "tools"
+ ? Wrench
+ : activeModeResultKind === "answer"
+ ? Search
+ : FileText,
href: "#search",
- count: searchMode === "documents" ? documentMatches.length : null,
- empty: searchMode === "documents" && documentMatches.length === 0,
+ count:
+ activeModeResultKind === "favourites"
+ ? favouriteItems.length + favouriteSets.length
+ : activeModeResultKind === "tools"
+ ? toolCatalog.length
+ : activeModeResultKind === "documents"
+ ? documentMatches.length
+ : null,
+ empty: activeModeResultKind === "documents" && documentMatches.length === 0,
},
{
label: "Quotes",
@@ -6693,6 +8394,36 @@ export function ClinicalDashboard() {
setUploadMobileTab("jobs");
void refresh({ includeSetup: false, includeDashboardData: true, includeDocumentMeta: false });
};
+ const documentsDrawerIsAdmin = documentsDrawerMode === "admin" && canUsePrivateApis;
+ const documentsDrawerTitle =
+ documentsDrawerMode === "recent"
+ ? "Recent documents"
+ : documentsDrawerMode === "source"
+ ? "Source PDFs"
+ : documentsDrawerIsAdmin
+ ? "Document admin"
+ : "Source library";
+ const documentsDrawerSummary = dashboardDataLoading
+ ? "Loading indexed document status."
+ : indexedDocumentTotal
+ ? documentsDrawerMode === "recent"
+ ? `${indexedDocumentTotal.toLocaleString()} indexed sources, sorted by recent updates`
+ : documentsDrawerMode === "source"
+ ? "Open original PDF source documents"
+ : documentsDrawerIsAdmin
+ ? `${indexedDocumentTotal.toLocaleString()} indexed documents available`
+ : `${indexedDocumentTotal.toLocaleString()} indexed sources available`
+ : "No indexed documents yet.";
+ const documentsDrawerMobileSummary = dashboardDataLoading
+ ? "Loading library"
+ : documentsDrawerMode === "recent"
+ ? "Recent sources"
+ : documentsDrawerMode === "source"
+ ? "PDF sources"
+ : documentsDrawerIsAdmin
+ ? "Admin"
+ : "Library";
+ const drawerGroupTitle = uploadDrawerOpen || documentsDrawerIsAdmin ? "Library and admin" : "Sources";
return (
setQuery("")}
onClearScope={() => setSelectedDocumentIds([])}
@@ -6741,6 +8474,8 @@ export function ClinicalDashboard() {
onOpenMobileSidebar={() => setMobileSidebarOpen(true)}
onToggleTheme={toggleTheme}
queryModeOptions={clinicalQueryModeOptions}
+ queryInputRef={composerInputRef}
+ queryInputAutoFocus={focusSearch}
/>
- {searchMode === "answer" ? "Answer" : "Document matches"}
+ {activeModeSearch.resultHeading}
{error && (
)}
- {loading && answerProgress && (
+ {loading && answerProgress && searchMode !== "prescribing" && (
)}
- {searchMode === "documents" ? (
- <>
-
-
setQuery("")}
+ onAddFavourite={() =>
+ setActionNotice({
+ tone: "success",
+ message: "Favourite actions are ready for the selected answer, medication, or source.",
+ })
+ }
+ />
+ ) : activeModeResultKind === "tools" ? (
+ setQuery("")} />
+ ) : activeModeResultKind === "documents" ? (
+ searchMode === "prescribing" ? (
+
- >
+ ) : (
+ <>
+
+
+ >
+ )
) : loading && !answer ? (
) : answer ? (
@@ -6868,7 +8635,7 @@ export function ClinicalDashboard() {
{showSystemNotice && answer ? renderSystemNotice("sm:hidden") : null}
- {searchMode === "answer" && answer && (
+ {activeModeResultKind === "answer" && answer && (
-
-
-
-
-
-
-
-
-
+ {documentsDrawerOpen ? (
+
- {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
-
-
-
+ ) : null}
+
+
+ ) : null}
+
+ {uploadDrawerOpen ? (
+
+
-
- Ingestion quality console
-
-
+ {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 &&
}
+
+
+
+ Clinical upload
+
+
+
+
+
+ Indexing progress
+
+
+
+
+
+ Ingestion quality console
+
+
+
+
+
+ ) : null}
)}
@@ -7075,8 +8839,9 @@ export function ClinicalDashboard() {
recentQueries={recentQueries}
onOpenChange={setMobileSidebarOpen}
onNewChat={startNewChat}
- onPickRecent={setQuery}
+ onPickRecent={pickRecentQuery}
onOpenGuide={openGuide}
+ onPrefetchApplications={prefetchApplications}
/>
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 bc630635ea..453c688b09 100644
--- a/src/components/DocumentViewer.tsx
+++ b/src/components/DocumentViewer.tsx
@@ -2087,9 +2087,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/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx
new file mode 100644
index 0000000000..39ba7b6483
--- /dev/null
+++ b/src/components/applications-launcher-page.tsx
@@ -0,0 +1,1162 @@
+"use client";
+
+import Link from "next/link";
+import {
+ BookOpen,
+ Brain,
+ Check,
+ ChevronRight,
+ ClipboardList,
+ ExternalLink,
+ FileText,
+ Grid2X2,
+ Globe2,
+ ListChecks,
+ Mic,
+ Menu,
+ MoreVertical,
+ Pill,
+ Pin,
+ PinOff,
+ Plus,
+ Puzzle,
+ Search,
+ Send,
+ Settings,
+ Sparkles,
+ Star,
+ Stethoscope,
+ Users,
+ X,
+ type LucideIcon,
+} from "lucide-react";
+import { type FormEvent, useMemo, useState } from "react";
+
+import { Sheet } from "@/components/ui/sheet";
+import {
+ chatComposerIconButton,
+ chatComposerInput,
+ chatComposerShell,
+ chatSendButton,
+ cn,
+ sidebarItem,
+ sidebarToolTile as sidebarApplicationTile,
+ textMuted,
+} from "@/components/ui-primitives";
+import { toolCatalog } from "@/lib/tools";
+
+type LauncherStatus = "ready" | "recent" | "review_due";
+type LauncherCategory = "clinical" | "admin" | "recent";
+
+type LauncherApp = {
+ id: string;
+ title: string;
+ shortTitle?: string;
+ description: string;
+ detail: string;
+ href: string;
+ external: boolean;
+ icon: LucideIcon;
+ category: LauncherCategory;
+ workflow: string;
+ lastUsed: string;
+ status: LauncherStatus;
+ sourceToolId?: string;
+ relatedIds: string[];
+ quickActions: string[];
+ recentWorkflows: Array<{ title: string; date: string }>;
+};
+
+const statusStyles: Record = {
+ ready: "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]",
+ recent: "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]",
+ review_due: "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]",
+};
+
+const statusLabels: Record = {
+ ready: "Ready",
+ recent: "Recent",
+ review_due: "Review due",
+};
+
+const filterOptions = [
+ { id: "all", label: "All" },
+ { id: "clinical", label: "Clinical" },
+ { id: "admin", label: "Admin" },
+ { id: "recent", label: "Recent" },
+] as const;
+
+const sidebarApplicationItems = [
+ { label: "Formulation", icon: Brain, href: "/applications" },
+ { label: "DSM-5", icon: BookOpen, href: "/applications" },
+ { label: "Meds", icon: Pill, href: "/?mode=prescribing" },
+ { label: "Diffs", icon: Search, href: "/applications" },
+] as const;
+
+function toolById(id: string) {
+ return toolCatalog.find((tool) => tool.id === id);
+}
+
+function hrefForTool(id: string, fallback: string) {
+ return toolById(id)?.href ?? fallback;
+}
+
+function isExternalTool(id: string) {
+ return toolById(id)?.target === "external";
+}
+
+const launcherApps: LauncherApp[] = [
+ {
+ id: "differential-diagnosis",
+ title: "Differential Diagnosis",
+ description: "Generate and explore differential diagnoses.",
+ detail: "Compare likely differentials, rule-outs, red flags, and competing diagnostic explanations.",
+ href: hrefForTool("differentials", "http://127.0.0.1:53375"),
+ external: isExternalTool("differentials"),
+ icon: Stethoscope,
+ category: "recent",
+ workflow: "Assessment",
+ lastUsed: "Today, 10:24 AM",
+ status: "recent",
+ sourceToolId: "differentials",
+ relatedIds: ["specifiers", "services", "clinical-kb-search"],
+ quickActions: ["Start differential", "Review red flags", "Compare rule-outs", "Open assessment history"],
+ recentWorkflows: [
+ { title: "Chest pain differential", date: "Today, 10:24 AM" },
+ { title: "Headache workup", date: "Yesterday, 3:11 PM" },
+ { title: "Fatigue assessment", date: "May 10, 2025" },
+ ],
+ },
+ {
+ id: "specifiers",
+ title: "Specifiers",
+ description: "Add clinical specifiers and refine conditions.",
+ detail: "Review severity, course, qualifiers, and specifier language for a diagnosis.",
+ href: hrefForTool("specifiers", "http://127.0.0.1:58123"),
+ external: isExternalTool("specifiers"),
+ icon: ListChecks,
+ category: "clinical",
+ workflow: "Assessment",
+ lastUsed: "Yesterday, 4:15 PM",
+ status: "ready",
+ sourceToolId: "specifiers",
+ relatedIds: ["differential-diagnosis", "formulation", "clinical-kb-search"],
+ quickActions: ["Open specifier review", "Check course descriptors", "Review severity", "Browse qualifiers"],
+ recentWorkflows: [
+ { title: "Mood episode specifiers", date: "Yesterday, 4:15 PM" },
+ { title: "Anxiety course review", date: "May 11, 2025" },
+ { title: "Psychosis qualifiers", date: "May 8, 2025" },
+ ],
+ },
+ {
+ id: "services",
+ title: "Services",
+ description: "Browse and manage clinical services.",
+ detail: "Find referral pathways, access points, service matching, and destination options.",
+ href: hrefForTool("services", "http://127.0.0.1:53174"),
+ external: isExternalTool("services"),
+ icon: Users,
+ category: "clinical",
+ workflow: "Care planning",
+ lastUsed: "May 12, 2025",
+ status: "ready",
+ sourceToolId: "services",
+ relatedIds: ["formulation", "documents", "clinical-kb-search"],
+ quickActions: ["Find referral pathway", "Browse service options", "Review access criteria", "Open saved pathway"],
+ recentWorkflows: [
+ { title: "Community referral options", date: "May 12, 2025" },
+ { title: "Crisis pathway review", date: "May 9, 2025" },
+ { title: "Outpatient access check", date: "May 7, 2025" },
+ ],
+ },
+ {
+ id: "formulation",
+ title: "Formulation",
+ description: "Create and manage clinical formulations.",
+ detail: "Structure case formulations to support assessment, conceptualisation, care planning, and team reuse.",
+ href: hrefForTool("formulation", "http://localhost:53210"),
+ external: isExternalTool("formulation"),
+ icon: Puzzle,
+ category: "recent",
+ workflow: "Care planning",
+ lastUsed: "Today, 9:02 AM",
+ status: "recent",
+ sourceToolId: "formulation",
+ relatedIds: ["differential-diagnosis", "medication-prescribing", "documents", "clinical-kb-search"],
+ quickActions: ["Create new formulation", "Browse my formulations", "Shared with me", "Formulation templates"],
+ recentWorkflows: [
+ { title: "Mood & anxiety formulation - Jane D.", date: "Today, 9:02 AM" },
+ { title: "Complex case review - M. Smith", date: "May 12, 2025" },
+ { title: "Care plan formulation - P. Johnson", date: "May 9, 2025" },
+ { title: "Discharge formulation - K. Patel", date: "May 7, 2025" },
+ ],
+ },
+ {
+ id: "medication-prescribing",
+ title: "Medication Prescribing",
+ shortTitle: "Medication",
+ description: "Search, prescribe and review medications.",
+ detail: "Check prescribing context, monitoring, interactions, and medication-specific safety issues.",
+ href: "/?mode=prescribing",
+ external: false,
+ icon: Pill,
+ category: "clinical",
+ workflow: "Care planning",
+ lastUsed: "May 12, 2025",
+ status: "review_due",
+ sourceToolId: "medications",
+ relatedIds: ["formulation", "documents", "clinical-kb-search"],
+ quickActions: ["Create new prescription", "Browse formulary", "Review interactions", "Medication templates"],
+ recentWorkflows: [
+ { title: "Medication review - Sam T.", date: "May 12, 2025" },
+ { title: "Repeat prescription - Atorvastatin 20mg", date: "May 9, 2025" },
+ { title: "Renal dose screen", date: "May 8, 2025" },
+ ],
+ },
+ {
+ id: "documents",
+ title: "Documents",
+ description: "Access and manage clinical documents.",
+ detail: "Search indexed PDFs, guidelines, policies, notes, and source documents.",
+ href: "/?mode=documents",
+ external: false,
+ icon: FileText,
+ category: "admin",
+ workflow: "Reference",
+ lastUsed: "May 10, 2025",
+ status: "ready",
+ relatedIds: ["clinical-kb-search", "favourites", "formulation"],
+ quickActions: ["Search documents", "Browse library", "Open source PDF", "Review indexed documents"],
+ recentWorkflows: [
+ { title: "Lithium monitoring guideline", date: "May 10, 2025" },
+ { title: "Safety plan source review", date: "May 9, 2025" },
+ { title: "Clozapine monitoring protocol", date: "May 7, 2025" },
+ ],
+ },
+ {
+ id: "favourites",
+ title: "Favourites",
+ description: "View and manage your saved items.",
+ detail: "Open saved sources, medications, documents, workflows, and reusable clinical sets.",
+ href: "/?mode=favourites",
+ external: false,
+ icon: Star,
+ category: "recent",
+ workflow: "Reference",
+ lastUsed: "Today, 8:45 AM",
+ status: "recent",
+ relatedIds: ["documents", "clinical-kb-search", "formulation"],
+ quickActions: ["Open saved items", "Manage pinned sets", "Review due favourites", "Add current answer"],
+ recentWorkflows: [
+ { title: "Ward round set", date: "Today, 8:45 AM" },
+ { title: "Prescribing safety set", date: "May 12, 2025" },
+ { title: "Document QA set", date: "May 10, 2025" },
+ ],
+ },
+ {
+ id: "clinical-kb-search",
+ title: "Clinical KB Search",
+ description: "Search the knowledge base content.",
+ detail: "Search the Clinical KB for source-backed guidance, answers, and evidence.",
+ href: "/?mode=answer",
+ external: false,
+ icon: Search,
+ category: "clinical",
+ workflow: "Reference",
+ lastUsed: "Today, 7:30 AM",
+ status: "ready",
+ relatedIds: ["documents", "favourites", "differential-diagnosis"],
+ quickActions: ["Ask clinical question", "Search indexed guidelines", "Open document scope", "Review sources"],
+ recentWorkflows: [
+ { title: "Lithium monitoring search", date: "Today, 7:30 AM" },
+ { title: "Safety plan search", date: "May 12, 2025" },
+ { title: "Clozapine source check", date: "May 10, 2025" },
+ ],
+ },
+];
+
+const seedPinnedIds = ["formulation", "differential-diagnosis", "medication-prescribing"];
+
+const recentActivity = [
+ { id: "formulation", label: "Formulation opened", date: "Today, 9:02 AM", icon: Puzzle },
+ {
+ id: "differential-diagnosis",
+ label: "Differential Diagnosis launched",
+ date: "Today, 10:24 AM",
+ icon: Stethoscope,
+ },
+ { id: "medication-prescribing", label: "Medication Prescribing reviewed", date: "May 12, 2025", icon: Pill },
+ { id: "documents", label: "Documents opened", date: "May 10, 2025", icon: FileText },
+] as const;
+
+function appById(id: string) {
+ return launcherApps.find((app) => app.id === id) ?? launcherApps[0];
+}
+
+function StatusPill({ status }: { status: LauncherStatus }) {
+ return (
+
+ {statusLabels[status]}
+
+ );
+}
+
+function AppIcon({ app, compact = false }: { app: LauncherApp; compact?: boolean }) {
+ const Icon = app.icon;
+ return (
+
+
+
+ );
+}
+
+function LaunchLink({ app, compact = false, className }: { app: LauncherApp; compact?: boolean; className?: string }) {
+ const label = `Launch ${app.title}`;
+ const classes = cn(
+ "inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-[color:var(--clinical-chat-teal)] px-4 text-sm font-semibold text-white shadow-[var(--shadow-tight)] hover:bg-[color:var(--primary-strong)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
+ compact && "min-h-9 px-3 text-xs",
+ className,
+ );
+
+ if (app.external) {
+ return (
+
+ Launch
+
+
+ );
+ }
+
+ return (
+
+ Launch
+
+
+ );
+}
+
+function HeaderFilter({
+ activeFilter,
+ onFilterChange,
+}: {
+ activeFilter: (typeof filterOptions)[number]["id"];
+ onFilterChange: (filter: (typeof filterOptions)[number]["id"]) => void;
+}) {
+ return (
+
+ {filterOptions.map((option) => {
+ const active = option.id === activeFilter;
+ return (
+ onFilterChange(option.id)}
+ className={cn(
+ "min-h-11 px-4 text-xs font-semibold transition sm:min-w-[5.25rem]",
+ active
+ ? "bg-[color:var(--clinical-chat-teal)] text-white shadow-[var(--shadow-tight)]"
+ : "border-l border-[color:var(--border)] text-[color:var(--text-muted)] first:border-l-0 hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]",
+ )}
+ >
+ {option.label}
+
+ );
+ })}
+
+ );
+}
+
+function PinnedSection({
+ pinnedApps,
+ selectedId,
+ onSelect,
+ onTogglePin,
+}: {
+ pinnedApps: LauncherApp[];
+ selectedId: string;
+ onSelect: (id: string) => void;
+ onTogglePin: (id: string) => void;
+}) {
+ return (
+
+
+
+
{pinnedApps.length} pinned
+
+
+ {pinnedApps.map((app) => {
+ const selected = selectedId === app.id;
+ return (
+
+
onSelect(app.id)}
+ className="contents text-left"
+ aria-label={`Select ${app.title}`}
+ >
+
+
+
+ {app.title}
+
+ {app.description}
+
+
+
+
{
+ event.stopPropagation();
+ onTogglePin(app.id);
+ }}
+ aria-label={`Unpin ${app.title}`}
+ className="grid h-9 w-9 place-items-center rounded-lg text-[color:var(--text-muted)] hover:bg-[color:var(--surface)] hover:text-[color:var(--clinical-chat-teal)]"
+ >
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+function ApplicationRow({
+ app,
+ selected,
+ pinned,
+ onSelect,
+ onTogglePin,
+}: {
+ app: LauncherApp;
+ selected: boolean;
+ pinned: boolean;
+ onSelect: (id: string) => void;
+ onTogglePin: (id: string) => void;
+}) {
+ return (
+
+
onSelect(app.id)} className="contents text-left">
+
+
+ {app.title}
+ {app.description}
+
+ {app.lastUsed}
+
+
+
+
onTogglePin(app.id)}
+ aria-label={`${pinned ? "Unpin" : "Pin"} ${app.title}`}
+ className="grid h-9 w-9 place-items-center rounded-lg text-[color:var(--text-muted)] hover:bg-[color:var(--surface)] hover:text-[color:var(--clinical-chat-teal)]"
+ >
+
+
+
+ );
+}
+
+function MobileApplicationRow({
+ app,
+ selected,
+ onSelect,
+}: {
+ app: LauncherApp;
+ selected: boolean;
+ onSelect: (id: string) => void;
+}) {
+ return (
+ onSelect(app.id)}
+ data-testid={`mobile-application-row-${app.id}`}
+ className={cn(
+ "grid min-h-[62px] w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 border-t border-[color:var(--border)] px-3 py-2.5 text-left first:border-t-0",
+ selected &&
+ "rounded-lg border border-[color:var(--clinical-chat-teal)] bg-[color:var(--clinical-chat-teal-soft)]/60",
+ )}
+ >
+
+
+ {app.title}
+ {app.lastUsed}
+
+
+
+
+
+
+ );
+}
+
+function DetailPanel({
+ app,
+ pinned,
+ onTogglePin,
+ onClose,
+ headingId,
+ testId = "selected-application-panel",
+ variant = "inline",
+}: {
+ app: LauncherApp;
+ pinned: boolean;
+ onTogglePin: (id: string) => void;
+ onClose?: () => void;
+ headingId?: string;
+ testId?: string;
+ variant?: "inline" | "sheet";
+}) {
+ const related = app.relatedIds.map(appById);
+
+ return (
+
+
+
+ Selected application
+
+
+
onTogglePin(app.id)}
+ aria-label={`${pinned ? "Unpin" : "Pin"} ${app.title}`}
+ className="grid h-9 w-9 place-items-center rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] shadow-[var(--shadow-inset)] hover:text-[color:var(--clinical-chat-teal)]"
+ >
+
+
+ {onClose ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+
{app.title}
+
{app.description}
+
+
+
+
+
+
+
+
+
+ Overview
+ {app.detail}
+
+
+
+ Quick actions
+
+ {app.quickActions.map((action) => (
+
+
+ {action}
+
+
+ ))}
+
+
+
+
+
+
Recent workflows
+
+ View all
+
+
+
+ {app.recentWorkflows.slice(0, 4).map((workflow) => (
+
+ {workflow.title}
+ {workflow.date}
+
+ ))}
+
+
+
+
+
+
Recent activity
+
+ View all
+
+
+
+ {recentActivity.map((item) => {
+ const Icon = item.icon;
+ return (
+
+
+ {item.label}
+ {item.date}
+
+ );
+ })}
+
+
+
+
+ Related apps
+
+ {related.map((relatedApp) => {
+ const Icon = relatedApp.icon;
+ return (
+
+
+ {relatedApp.shortTitle ?? relatedApp.title}
+
+ );
+ })}
+
+
+
+ );
+}
+
+function ApplicationsMobileMenu({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
+ const recentQueries = ["lithium", "tables", "dosing for lithium", "management of bulimia nervosa"];
+
+ return (
+ onOpenChange(false)}
+ title="Clinical Guide"
+ description="Recent chats, applications, help, and settings."
+ closeLabel="Close Clinical Guide menu"
+ placement="left"
+ contentClassName="lg:hidden"
+ >
+
+
onOpenChange(false)}
+ className="inline-flex min-h-[44px] w-full items-center justify-center gap-2 rounded-lg bg-[color:var(--clinical-chat-teal)] px-3 text-sm font-semibold text-white shadow-[var(--shadow-tight)] hover:bg-[color:var(--primary-strong)]"
+ >
+
+ New chat
+
+
+
+
+
+
+
+
+
+ Recent chats
+
+
+ {recentQueries.map((recent) => (
+ onOpenChange(false)}>
+
+ {recent}
+
+ ))}
+
+
+
+
+
+ Applications
+
+
+ {sidebarApplicationItems.map((item) => {
+ const Icon = item.icon;
+ return (
+ onOpenChange(false)}
+ className={sidebarApplicationTile}
+ >
+
+ {item.label}
+
+ );
+ })}
+
+
+
+
+
onOpenChange(false)}>
+
+ Guide & help
+
+
+
+ Settings
+
+
+
+ AK
+
+
+ Dr A. Khan
+ Ready
+
+
+
+
+
+ );
+}
+
+function ApplicationsModeMenu({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
+ const options = [
+ {
+ label: "Answer",
+ description: "Source-backed clinical answer",
+ href: "/?mode=answer",
+ icon: Sparkles,
+ active: false,
+ },
+ {
+ label: "Documents",
+ description: "Search indexed PDFs and notes",
+ href: "/?mode=documents",
+ icon: FileText,
+ active: false,
+ },
+ {
+ label: "Favourites",
+ description: "Saved sources and workflows",
+ href: "/?mode=favourites",
+ icon: Star,
+ active: false,
+ },
+ {
+ label: "Applications",
+ description: "Launch connected applications",
+ href: "/applications",
+ icon: Grid2X2,
+ active: true,
+ },
+ ] as const;
+
+ if (!open) return null;
+
+ return (
+
+ {options.map((option) => {
+ const Icon = option.icon;
+ return (
+ onOpenChange(false)}
+ aria-current={option.active ? "page" : undefined}
+ className={cn(
+ "grid min-h-[3.25rem] w-full grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-2 rounded-md 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)]",
+ option.active
+ ? "bg-[color:var(--clinical-chat-teal-soft)] text-[color:var(--clinical-chat-teal)]"
+ : "text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]",
+ )}
+ >
+
+
+
+
+ {option.label}
+
+ {option.description}
+
+
+ {option.active ? : null}
+
+ );
+ })}
+
+ );
+}
+
+function ApplicationsHeader({
+ mobileMenuOpen,
+ modeMenuOpen,
+ onMobileMenuOpenChange,
+ onModeMenuOpenChange,
+}: {
+ mobileMenuOpen: boolean;
+ modeMenuOpen: boolean;
+ onMobileMenuOpenChange: (open: boolean) => void;
+ onModeMenuOpenChange: (open: boolean) => void;
+}) {
+ return (
+
+
+
onMobileMenuOpenChange(true)}
+ className="grid h-11 w-11 shrink-0 place-items-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] lg:hidden"
+ aria-label="Open Clinical Guide menu"
+ aria-expanded={mobileMenuOpen}
+ >
+
+
+
+
+
onModeMenuOpenChange(!modeMenuOpen)}
+ className="inline-grid h-11 min-w-[10rem] grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-full border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 text-left shadow-[var(--shadow-inset)] transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-w-[14rem]"
+ aria-haspopup="true"
+ aria-expanded={modeMenuOpen}
+ aria-label="Current app mode: Applications"
+ >
+
+
+
+
+
+ Mode
+
+
+ Applications
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function ApplicationsLauncherPage() {
+ const [query, setQuery] = useState("");
+ const [activeFilter, setActiveFilter] = useState<(typeof filterOptions)[number]["id"]>("all");
+ const [selectedId, setSelectedId] = useState("formulation");
+ const [pinnedIds, setPinnedIds] = useState(seedPinnedIds);
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
+ const [modeMenuOpen, setModeMenuOpen] = useState(false);
+ const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
+
+ const normalizedQuery = query.trim().toLowerCase();
+ const pinnedApps = pinnedIds.map(appById);
+ const selectedApp = appById(selectedId);
+
+ const filteredApps = useMemo(() => {
+ return launcherApps.filter((app) => {
+ const matchesFilter =
+ activeFilter === "all" ||
+ (activeFilter === "recent"
+ ? app.status === "recent" || app.category === "recent"
+ : app.category === activeFilter);
+ const matchesQuery =
+ !normalizedQuery ||
+ [app.title, app.description, app.workflow, app.detail].some((value) =>
+ value.toLowerCase().includes(normalizedQuery),
+ );
+ return matchesFilter && matchesQuery;
+ });
+ }, [activeFilter, normalizedQuery]);
+
+ function togglePin(id: string) {
+ setPinnedIds((current) => (current.includes(id) ? current.filter((item) => item !== id) : [id, ...current]));
+ }
+
+ function selectApplication(id: string) {
+ setSelectedId(id);
+ if (typeof window !== "undefined" && window.matchMedia("(max-width: 1023px)").matches) {
+ setMobileDetailOpen(true);
+ }
+ }
+
+ function submitFooterSearch(event: FormEvent) {
+ event.preventDefault();
+ const firstMatch = filteredApps[0];
+ if (firstMatch) selectApplication(firstMatch.id);
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Applications
+
+
+ Open the clinical applications and connected workflows you use for assessment, formulation, prescribing,
+ documents, and saved workflows.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
All applications
+
+
+
+ Application
+ Last used
+ Status
+ Action
+
+
+
+
+ {filteredApps.map((app) => (
+
+ ))}
+
+
+
+ {filteredApps.map((app) => (
+
+ ))}
+
+
+
+ Showing {filteredApps.length > 0 ? "1" : "0"} to {filteredApps.length} of {launcherApps.length}{" "}
+ applications
+
+
+
+
+
+
Recent activity
+
+ View all
+
+
+
+ {recentActivity.slice(0, 3).map((item) => {
+ const Icon = item.icon;
+ return (
+ selectApplication(item.id)}
+ className="grid min-h-14 grid-cols-[auto_minmax(0,1fr)] items-center gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-left shadow-[var(--shadow-inset)] hover:bg-[color:var(--surface-subtle)]"
+ >
+
+
+
+ {item.label}
+
+ {item.date}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
setMobileDetailOpen(false)}
+ labelledBy="selected-application-sheet-heading"
+ closeLabel="Close selected application"
+ contentClassName="lg:hidden rounded-t-[1.75rem] bg-[color:var(--surface-lux)]"
+ bodyClassName="px-5 pb-6 pt-4 sm:px-5"
+ portal
+ >
+ setMobileDetailOpen(false)}
+ headingId="selected-application-sheet-heading"
+ testId="selected-application-sheet-panel"
+ variant="sheet"
+ />
+
+
+
+
+ );
+}
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 e7afb4e704..abe7dba79a 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";
@@ -191,15 +192,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) {
@@ -233,6 +255,21 @@ function relevanceTone(document: DocumentMatch) {
return { label: "Related", short: "Related", 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));
@@ -241,27 +278,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"}
+
);
}
@@ -302,6 +430,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
@@ -347,6 +532,9 @@ export function DocumentSearchResultsPanel({
facets: _facets,
onScopeDocument,
onAnswerFromDocument,
+ onOpenRecentDocuments,
+ onOpenLibrary,
+ onOpenSourcePdf,
onTagSearch,
}: {
matches: DocumentMatch[];
@@ -360,6 +548,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;
@@ -442,10 +633,23 @@ export function DocumentSearchResultsPanel({
) : (
-
+
)
) : (
<>
+
{resultTabs.length > 1 ? (
{resultTabs.map((tab) => {
@@ -501,82 +705,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
@@ -584,7 +790,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/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx
new file mode 100644
index 0000000000..10390b6727
--- /dev/null
+++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx
@@ -0,0 +1,175 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { FileText, Heart, ListChecks, Pill, Search, Sparkles, Wrench } from "lucide-react";
+import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react";
+
+import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header";
+import { useTheme } from "@/components/clinical-dashboard/use-theme";
+import { Sheet } from "@/components/ui/sheet";
+import { cn, sidebarItem } from "@/components/ui-primitives";
+import {
+ appModeDefinition,
+ appModeHomeHref,
+ isAppModeId,
+ isAppModeVisible,
+ visibleAppModeDefinitions,
+ type AppModeId,
+} from "@/lib/app-modes";
+import type { SearchScopeFilters } from "@/lib/search-scope";
+import type { ClinicalQueryMode } from "@/lib/types";
+
+const mockupQueryModeOptions: Array<{ value: ClinicalQueryMode; label: string }> = [
+ { value: "auto", label: "Auto" },
+ { value: "monitoring_schedule", label: "Monitoring" },
+ { value: "dose_threshold_lookup", label: "Dose / thresholds" },
+ { value: "contraindications_cautions", label: "Cautions" },
+ { value: "escalation_criteria", label: "Escalation" },
+ { value: "required_documentation", label: "Documentation" },
+ { value: "compare_guidance", label: "Compare" },
+];
+
+const appModeIcons: Record = {
+ answer: Sparkles,
+ documents: FileText,
+ prescribing: Pill,
+ evidence: ListChecks,
+ favourites: Heart,
+ tools: Wrench,
+};
+
+export function GlobalMockupSearchShell({ children }: { children: ReactNode }) {
+ const router = useRouter();
+ const inputRef = useRef(null);
+ const [query, setQuery] = useState("");
+ const [searchMode, setSearchMode] = useState("answer");
+ const [queryMode, setQueryMode] = useState("auto");
+ const [scopeFilters, setScopeFilters] = useState({});
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
+ const { theme, toggleTheme } = useTheme();
+
+ useEffect(() => {
+ const frame = window.requestAnimationFrame(() => {
+ const params = new URLSearchParams(window.location.search);
+ const requestedMode = params.get("mode");
+ if (isAppModeId(requestedMode) && isAppModeVisible(requestedMode)) setSearchMode(requestedMode);
+
+ const requestedQuery = params.get("q")?.trim();
+ if (requestedQuery) setQuery(requestedQuery);
+
+ if (params.get("focus") === "1") inputRef.current?.focus({ preventScroll: true });
+ });
+ return () => window.cancelAnimationFrame(frame);
+ }, []);
+
+ function navigateToMode(mode: AppModeId, options: { query?: string; run?: boolean; focus?: boolean } = {}) {
+ router.push(appModeHomeHref(mode, options));
+ }
+
+ function submitSearch() {
+ const trimmedQuery = query.trim();
+ navigateToMode(searchMode, {
+ query: trimmedQuery || undefined,
+ run: Boolean(trimmedQuery),
+ focus: true,
+ });
+ }
+
+ function changeMode(mode: AppModeId) {
+ setSearchMode(mode);
+ setMobileMenuOpen(false);
+ navigateToMode(mode, { focus: true });
+ }
+
+ function startNewChat() {
+ setQuery("");
+ setSearchMode("answer");
+ setMobileMenuOpen(false);
+ navigateToMode("answer", { focus: true });
+ }
+
+ return (
+
+
setQuery("")}
+ onClearScope={() => undefined}
+ onQueryModeChange={setQueryMode}
+ onScopeFiltersChange={setScopeFilters}
+ onToggleScope={() => undefined}
+ onOpenUpload={() => router.push(`${appModeHomeHref("documents", { focus: true })}#sources`)}
+ onOpenEvidence={() => navigateToMode("evidence", { focus: true })}
+ onNewChat={startNewChat}
+ onOpenMobileSidebar={() => setMobileMenuOpen(true)}
+ onToggleTheme={toggleTheme}
+ queryModeOptions={mockupQueryModeOptions}
+ scopeVariant="placeholder"
+ queryInputRef={inputRef}
+ modeAlignment="center"
+ />
+
+
+ {children}
+
+
+ setMobileMenuOpen(false)}
+ title="Clinical Guide"
+ description="Choose the search workspace."
+ closeLabel="Close Clinical Guide menu"
+ placement="left"
+ contentClassName="max-w-[min(20rem,calc(100vw-1rem))]"
+ >
+
+ {visibleAppModeDefinitions().map((mode) => {
+ const Icon = appModeIcons[mode.id];
+ const active = mode.id === searchMode;
+ const modeDefinition = appModeDefinition(mode.id);
+ return (
+ changeMode(mode.id)}
+ aria-current={active ? "page" : undefined}
+ className={cn(
+ sidebarItem,
+ "grid grid-cols-[2rem_minmax(0,1fr)] px-2.5 py-2 text-left",
+ active && "bg-[color:var(--clinical-chat-teal-soft)] text-[color:var(--clinical-chat-teal)]",
+ )}
+ >
+
+
+
+
+ {modeDefinition.label}
+
+ {modeDefinition.description}
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx
index e19a1bdd9f..68089c97d8 100644
--- a/src/components/clinical-dashboard/master-search-header.tsx
+++ b/src/components/clinical-dashboard/master-search-header.tsx
@@ -1,8 +1,10 @@
"use client";
-import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { FormEvent, useCallback, useEffect, useMemo, useRef, useState, type Ref } from "react";
import {
+ Activity,
+ CalendarDays,
Check,
CheckCircle2,
ChevronDown,
@@ -19,10 +21,12 @@ import {
Plus,
Search,
Send,
+ ShieldCheck,
Sparkles,
Sun,
- UserRound,
X,
+ Lock,
+ Wrench,
} from "lucide-react";
import { DocumentTagCloud } from "@/components/DocumentTagCloud";
@@ -37,6 +41,14 @@ import {
eyebrowText,
} from "@/components/ui-primitives";
import { Sheet } from "@/components/ui/sheet";
+import {
+ appModeDefinition,
+ appModeDefinitions,
+ appModeSearchConfig,
+ isSearchableAppMode,
+ visibleAppModeDefinitions,
+ type AppModeId,
+} from "@/lib/app-modes";
import { type ResolvedTheme } from "@/lib/theme";
import type { ClinicalDocument, ClinicalQueryMode } from "@/lib/types";
import type { SearchScopeFilters } from "@/lib/search-scope";
@@ -44,64 +56,15 @@ import { tagSearchText } from "@/lib/document-tags";
const mobileSheetMediaQuery = "(max-width: 639px)";
-type AppModeId = "answer" | "documents" | "prescribing" | "evidence" | "favourites" | "profile";
-
-const appModeOptions: Array<{
- id: AppModeId;
- label: string;
- description: string;
- icon: typeof Search;
- href?: string;
- devOnly?: boolean;
-}> = [
- {
- id: "answer",
- label: "Answer",
- description: "Source-backed clinical answer",
- icon: Sparkles,
- },
- {
- id: "documents",
- label: "Documents",
- description: "Search indexed PDFs and notes",
- icon: FileText,
- },
- {
- id: "prescribing",
- label: "Prescribing",
- description: "Medication checks and guidance",
- icon: Pill,
- href: "/mockups/medication-prescribing",
- devOnly: true,
- },
- {
- id: "evidence",
- label: "Evidence",
- description: "Tables, quotes, images, PDFs",
- icon: ListChecks,
- href: "/mockups/answer-evidence-popups",
- devOnly: true,
- },
- {
- id: "favourites",
- label: "Favourites",
- description: "Saved sources and workflows",
- icon: Heart,
- href: "/mockups/favourites-hub",
- devOnly: true,
- },
- {
- id: "profile",
- label: "Profile",
- description: "Home, preferences, review queue",
- icon: UserRound,
- href: "/mockups/user-home-profile",
- devOnly: true,
- },
-];
-
-const isDev = process.env.NODE_ENV === "development";
-const visibleAppModeOptions = appModeOptions.filter((mode) => !mode.devOnly || isDev);
+const visibleAppModeOptions = visibleAppModeDefinitions();
+const appModeIcons: Record = {
+ answer: Sparkles,
+ documents: FileText,
+ prescribing: Pill,
+ evidence: ListChecks,
+ favourites: Heart,
+ tools: Wrench,
+};
function splitFilterText(value: string) {
return value
@@ -128,6 +91,7 @@ function documentScopeMeta(document: ClinicalDocument) {
export function MasterSearchHeader({
documents,
+ documentTotal,
query,
searchMode,
loading,
@@ -151,10 +115,15 @@ export function MasterSearchHeader({
onOpenMobileSidebar,
onToggleTheme,
queryModeOptions,
+ scopeVariant = "full",
+ queryInputRef,
+ queryInputAutoFocus = false,
+ modeAlignment = "default",
}: {
documents: ClinicalDocument[];
+ documentTotal?: number;
query: string;
- searchMode: "answer" | "documents";
+ searchMode: AppModeId;
loading: boolean;
selectedDocumentIds: string[];
queryMode: ClinicalQueryMode;
@@ -162,7 +131,7 @@ export function MasterSearchHeader({
realDataReady: boolean;
theme: ResolvedTheme;
onQueryChange: (query: string) => void;
- onSearchModeChange: (mode: "answer" | "documents") => void;
+ onSearchModeChange: (mode: AppModeId) => void;
onAsk: () => void;
onClearQuery: () => void;
onClearScope: () => void;
@@ -176,9 +145,23 @@ export function MasterSearchHeader({
onOpenMobileSidebar?: () => void;
onToggleTheme: () => void;
queryModeOptions: Array<{ value: ClinicalQueryMode; label: string }>;
+ scopeVariant?: "full" | "placeholder";
+ queryInputRef?: Ref;
+ queryInputAutoFocus?: boolean;
+ modeAlignment?: "default" | "center";
}) {
const trimmedQuery = query.trim();
- const canAsk = trimmedQuery.length >= 1 && !loading && realDataReady;
+ const selectedSearch = appModeSearchConfig(searchMode);
+ const selectedAppMode = appModeDefinition(searchMode);
+ 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 indexedDocumentTotal = documentTotal ?? documents.length;
+ const hasUnloadedDocuments = indexedDocumentTotal > documents.length;
+ const loadedScopeSummary = hasUnloadedDocuments
+ ? `${documents.length.toLocaleString()} loaded of ${indexedDocumentTotal.toLocaleString()}`
+ : `${documents.length.toLocaleString()} available`;
const [scopeFilter, setScopeFilter] = useState("");
const [scopeOpen, setScopeOpen] = useState(false);
const [scopeSheetOpen, setScopeSheetOpen] = useState(false);
@@ -186,6 +169,7 @@ export function MasterSearchHeader({
const [modeMenuOpen, setModeMenuOpen] = useState(false);
const [usesScopeSheet, setUsesScopeSheet] = useState(false);
const dailyActionButtonRef = useRef(null);
+ const dailyActionsMenuRef = useRef(null);
const firstDailyActionRef = useRef(null);
const modeMenuRef = useRef(null);
const scopeDetailsRef = useRef(null);
@@ -222,18 +206,59 @@ export function MasterSearchHeader({
const hiddenScopeMatchCount = requireScopeFilter
? Math.max(0, selectedDocuments.length ? documents.length - selectedDocumentIds.length : documents.length)
: Math.max(0, matchingDocuments.length - visibleScopeDocuments.length);
- const submitLabel = searchMode === "answer" ? (trimmedQuery ? "Answer" : "Ask") : "Docs";
- const queryPlaceholder = searchMode === "documents" ? "Search documents..." : "Ask a clinical question...";
- const selectedAppMode = appModeOptions.find((mode) => mode.id === searchMode) ?? appModeOptions[0];
- const SelectedAppModeIcon = selectedAppMode.icon;
- const dailyActions = [
- { label: "Search library", icon: Search },
- { label: "Add document", icon: FileText },
- { label: "Scope", icon: Filter },
- { label: "Evidence", icon: ListChecks },
- { label: "Clinical tools", icon: Sparkles },
- ] as const;
+ const submitLabel = trimmedQuery ? selectedSearch.submitBusyLabel : selectedSearch.submitIdleLabel;
+ const queryPlaceholder = selectedSearch.placeholder;
+ const SelectedAppModeIcon = appModeIcons[selectedAppMode.id];
+ const dailyActions =
+ searchMode === "prescribing"
+ ? ([
+ { label: "Dose", icon: CalendarDays },
+ { label: "Safety", icon: ShieldCheck },
+ { label: "Monitoring", icon: Activity },
+ { label: "Access", icon: Lock },
+ ] as const)
+ : ([
+ { label: "Search library", icon: Search },
+ { label: "Add document", icon: FileText },
+ { label: "Scope", icon: Filter },
+ { label: "Evidence", icon: ListChecks },
+ { label: "Tools", icon: Wrench },
+ ] as const);
+ const dailyActionsTitle = searchMode === "prescribing" ? "Medication checks" : "Daily actions";
+ const dailyActionsButtonLabel = searchMode === "prescribing" ? "Open medication checks" : "Open daily actions";
+ const dailyActionsDescription =
+ searchMode === "prescribing"
+ ? "Choose a dose, safety, monitoring, or access focus."
+ : "Search, add, scope, evidence, or tools.";
+
+ function currentUsesScopeSheet() {
+ return window.matchMedia(mobileSheetMediaQuery).matches;
+ }
+
function runDailyAction(label: (typeof dailyActions)[number]["label"]) {
+ if (searchMode === "prescribing") {
+ const medicationQuery = trimmedQuery || "acamprosate renal dose";
+ if (label === "Dose") {
+ onQueryModeChange("dose_threshold_lookup");
+ onQueryChange(medicationQuery);
+ return;
+ }
+ if (label === "Safety") {
+ onQueryModeChange("contraindications_cautions");
+ onQueryChange(trimmedQuery || "acamprosate contraindications");
+ return;
+ }
+ if (label === "Monitoring") {
+ onQueryModeChange("monitoring_schedule");
+ onQueryChange(trimmedQuery || "acamprosate monitoring");
+ return;
+ }
+ if (label === "Access") {
+ onQueryModeChange("required_documentation");
+ onQueryChange(trimmedQuery || "acamprosate PBS access");
+ return;
+ }
+ }
if (label === "Search library") {
onSearchModeChange("documents");
return;
@@ -243,7 +268,9 @@ export function MasterSearchHeader({
return;
}
if (label === "Scope") {
- if (usesScopeSheet) {
+ const nextUsesScopeSheet = currentUsesScopeSheet();
+ setUsesScopeSheet(nextUsesScopeSheet);
+ if (nextUsesScopeSheet) {
setScopeSheetOpen(true);
} else {
setScopeOpen(true);
@@ -256,16 +283,16 @@ export function MasterSearchHeader({
onOpenEvidence?.();
return;
}
- window.location.assign("/tools");
+ onSearchModeChange("tools");
}
- function selectAppMode(mode: (typeof appModeOptions)[number]) {
+ function selectAppMode(mode: (typeof appModeDefinitions)[number]) {
setModeMenuOpen(false);
- if (mode.id === "answer" || mode.id === "documents") {
+ if (isSearchableAppMode(mode.id)) {
onSearchModeChange(mode.id);
return;
}
- if (mode.href) window.location.assign(mode.href);
+ if ("href" in mode && mode.href) window.location.assign(mode.href);
}
const collectionOptions = useMemo(() => {
const values = new Set();
@@ -305,6 +332,31 @@ export function MasterSearchHeader({
onScopeOpenChange?.(scopeOpen || scopeSheetOpen);
}, [onScopeOpenChange, scopeOpen, scopeSheetOpen]);
+ useEffect(() => {
+ if (!dailyActionsOpen || usesScopeSheet) return undefined;
+
+ function handlePointerDown(event: PointerEvent) {
+ const target = event.target;
+ if (!(target instanceof Node)) return;
+ if (!dailyActionsMenuRef.current?.contains(target)) setDailyActionsOpen(false);
+ }
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ setDailyActionsOpen(false);
+ window.requestAnimationFrame(() => dailyActionButtonRef.current?.focus());
+ }
+ }
+
+ document.addEventListener("pointerdown", handlePointerDown);
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("pointerdown", handlePointerDown);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [dailyActionsOpen, usesScopeSheet]);
+
useEffect(() => {
if (!modeMenuOpen) return undefined;
@@ -452,7 +504,7 @@ export function MasterSearchHeader({
Document scope
- {selectedDocumentIds.length ? `${selectedDocumentIds.length} selected` : `${documents.length} available`}
+ {selectedDocumentIds.length ? `${selectedDocumentIds.length} selected` : loadedScopeSummary}
@@ -495,7 +547,8 @@ export function MasterSearchHeader({
{requireScopeFilter && visibleScopeDocuments.length === 0 ? (
- Type to filter {documents.length} documents. Selected documents stay pinned here.
+ Type to filter {documents.length.toLocaleString()} loaded documents. Selected documents stay pinned
+ here.
) : null}
{visibleScopeDocuments.map((document) => {
@@ -556,7 +609,7 @@ export function MasterSearchHeader({
{hiddenScopeMatchCount > 0 ? (
{requireScopeFilter
- ? `${documents.length} documents available. Type a title or file name to narrow the list.`
+ ? `${loadedScopeSummary} documents. Type a title or file name to narrow the loaded list.`
: `Showing ${visibleScopeDocuments.length} of ${matchingDocuments.length}. Keep typing to narrow the list.`}
) : null}
@@ -572,7 +625,7 @@ export function MasterSearchHeader({
id="search"
className="sticky top-0 z-30 border-b border-[color:var(--border)] bg-[color:var(--surface-lux)]/95 px-3 py-2 pt-[max(0.5rem,env(safe-area-inset-top))] text-[color:var(--text)] shadow-[var(--shadow-tight)] backdrop-blur-xl sm:px-4 lg:px-6"
>
-
+
-
+
setModeMenuOpen((open) => !open)}
@@ -617,7 +676,7 @@ export function MasterSearchHeader({
className="absolute left-1/2 top-[calc(100%+0.5rem)] z-50 w-[min(21rem,calc(100vw-4rem))] -translate-x-1/2 overflow-hidden rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] p-1.5 text-[color:var(--text)] shadow-[var(--shadow-lux)] ring-1 ring-white/25 backdrop-blur-md dark:ring-white/10 sm:left-0 sm:w-[min(21rem,calc(100vw-2rem))] sm:translate-x-0"
>
{visibleAppModeOptions.map((mode) => {
- const Icon = mode.icon;
+ const Icon = appModeIcons[mode.id];
const active = mode.id === searchMode;
return (
- {usesScopeSheet ? (
+ {scopeIsPlaceholder ? (
+
+
+ Scope
+
+ ) : usesScopeSheet ? (
{
@@ -755,23 +826,27 @@ export function MasterSearchHeader({
"fixed inset-x-3 bottom-3 z-40 mx-auto max-w-3xl sm:bottom-4 lg:left-[calc(var(--clinical-sidebar-width,20rem)+2rem)] lg:right-8 lg:max-w-4xl",
)}
>
-
+
setDailyActionsOpen((open) => !open)}
+ title={dailyActionsButtonLabel}
+ onClick={() => {
+ setUsesScopeSheet(currentUsesScopeSheet());
+ setDailyActionsOpen((open) => !open);
+ }}
>
{dailyActionsOpen && !usesScopeSheet ? (
{dailyActions.map((item) => {
@@ -797,12 +872,16 @@ export function MasterSearchHeader({
onQueryChange(event.currentTarget.value)}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") onAsk();
}}
- aria-label="Search indexed guidelines by question or keyword"
+ aria-label={`Search indexed guidelines by question or keyword - ${selectedSearch.inputAriaLabel}`}
placeholder={queryPlaceholder}
className={cn(chatComposerInput, "w-full min-w-0", query ? "pr-11" : null)}
/>
@@ -827,15 +906,11 @@ export function MasterSearchHeader({
!realDataReady
? "Search setup not ready"
: trimmedQuery.length < 1
- ? searchMode === "answer"
- ? "Enter a clinical question"
- : "Enter a document search term"
- : searchMode === "answer"
- ? "Generate a source-backed answer"
- : "Find matching documents"
+ ? selectedSearch.emptyTitle
+ : selectedSearch.readyTitle
}
className={chatSendButton}
- aria-label={searchMode === "answer" ? "Generate source-backed answer" : "Find matching documents"}
+ aria-label={selectedSearch.submitAriaLabel}
>
{loading ? : }
{submitLabel}
@@ -866,8 +941,8 @@ export function MasterSearchHeader({
setDailyActionsOpen(false)}
- title="Daily actions"
- description="Search, add, scope, evidence, or tools."
+ title={dailyActionsTitle}
+ description={dailyActionsDescription}
closeLabel="Close daily actions"
initialFocusRef={firstDailyActionRef}
returnFocusRef={dailyActionButtonRef}
@@ -888,7 +963,7 @@ export function MasterSearchHeader({
className={cn(
"grid min-h-[72px] place-items-center gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-3 text-center text-xs font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)]",
"hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
- index === dailyActions.length - 1 && "col-span-2",
+ dailyActions.length % 2 === 1 && index === dailyActions.length - 1 && "col-span-2",
)}
>
diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx
new file mode 100644
index 0000000000..d60a621eb6
--- /dev/null
+++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx
@@ -0,0 +1,1230 @@
+"use client";
+
+import {
+ Activity,
+ AlertTriangle,
+ ArrowLeft,
+ ArrowLeftRight,
+ BadgeCheck,
+ Brain,
+ CalendarDays,
+ ChartNoAxesColumnIncreasing,
+ CheckCircle2,
+ ChevronDown,
+ ChevronRight,
+ ClipboardCheck,
+ ClipboardList,
+ Droplet,
+ FileText,
+ FlaskConical,
+ Lock,
+ Pill,
+ ShieldCheck,
+ UserRound,
+ type LucideIcon,
+} from "lucide-react";
+import Link from "next/link";
+import { useState } from "react";
+
+import { cn } from "@/components/ui-primitives";
+
+type MedicationPrescribingWorkspaceProps = {
+ query: string;
+ loading: boolean;
+ realDataReady: boolean;
+ authUnavailable: boolean;
+ apiUnavailable: boolean;
+ setupWarning: string | null;
+ onSuggestedSearch: (searchText: string) => void;
+};
+
+type Capability = {
+ label: string;
+ description: string;
+ icon: LucideIcon;
+};
+
+type MedicationResult = {
+ id: string;
+ name: string;
+ indication: string;
+ match: string;
+ dose: string;
+ ceiling: string;
+ action: string;
+ tone: "teal" | "blue" | "slate";
+ href?: string;
+};
+
+type DetailRow = {
+ label: string;
+ icon: LucideIcon;
+ body?: string | string[];
+ columns?: Array<{ label: string; value: string; meta?: string }>;
+ tone?: "default" | "danger";
+ compact?: boolean;
+};
+
+type SideSection = {
+ title: string;
+ icon: LucideIcon;
+ items: Array<{ label: string; body: string; icon?: LucideIcon }>;
+};
+
+type MedicationResultFilter = "best" | "indication" | "safety" | "monitoring";
+
+const medicationResultFilters: Array<{ id: MedicationResultFilter; label: string }> = [
+ { id: "best", label: "Best" },
+ { id: "indication", label: "Indication" },
+ { id: "safety", label: "Safety" },
+ { id: "monitoring", label: "Monitor" },
+];
+
+const medicationCapabilities: Capability[] = [
+ {
+ label: "Dose",
+ description: "Dosing and adjustment",
+ icon: CalendarDays,
+ },
+ {
+ label: "Safety",
+ description: "Avoid and cautions",
+ icon: ShieldCheck,
+ },
+ {
+ label: "Monitoring",
+ description: "Baseline and follow-up",
+ icon: Activity,
+ },
+ {
+ label: "Access",
+ description: "PBS and brand",
+ icon: Lock,
+ },
+];
+
+const medicationPrompts = [
+ { label: "acamprosate renal dose", icon: Pill },
+ { label: "naltrexone opioid use", icon: UserRound },
+ { label: "sertraline max dose", icon: ShieldCheck },
+];
+
+const medicationResults: MedicationResult[] = [
+ {
+ id: "acamprosate",
+ name: "Acamprosate",
+ indication: "Alcohol abstinence maintenance",
+ match: "Exact renal dose match",
+ dose: "666 mg TID",
+ ceiling: "1,998 mg/day",
+ action: "Check renal function; avoid if serum creatinine >120 micromol/L.",
+ tone: "teal",
+ href: "/medications/acamprosate",
+ },
+ {
+ id: "naltrexone",
+ name: "Naltrexone",
+ indication: "Alcohol use disorder treatment",
+ match: "Good clinical fit",
+ dose: "50 mg daily",
+ ceiling: "50 mg/day",
+ action: "Check opioid use; risk of precipitated withdrawal.",
+ tone: "blue",
+ },
+ {
+ id: "disulfiram",
+ name: "Disulfiram",
+ indication: "Alcohol use disorder treatment",
+ match: "Caution fit",
+ dose: "250 mg daily",
+ ceiling: "500 mg/day",
+ action: "Counsel on alcohol reaction; check liver function.",
+ tone: "slate",
+ },
+ {
+ id: "baclofen",
+ name: "Baclofen",
+ indication: "Alcohol use disorder treatment (off-label)",
+ match: "Lower clinical fit",
+ dose: "5 mg TID",
+ ceiling: "80 mg/day",
+ action: "Specialist use; reduce dose in renal impairment; monitor sedation.",
+ tone: "slate",
+ },
+];
+
+const detailRows: DetailRow[] = [
+ {
+ label: "Prescribing answer",
+ icon: ClipboardList,
+ body: [
+ "Use for maintenance of abstinence after detox when renal function is acceptable and psychosocial support is in place.",
+ "Start after withdrawal and continue if relapse occurs. Not for use in acute withdrawal.",
+ ],
+ },
+ {
+ label: "Dosing",
+ icon: CalendarDays,
+ columns: [
+ { label: "Usual dose", value: "666 mg (2 x 333 mg) TID with meals" },
+ { label: "Dose ceiling", value: "1,998 mg/day", meta: "MAX" },
+ { label: "Under 60 kg", value: "2 tablets morning, 1 midday, 1 night" },
+ { label: "Treatment duration", value: "Around 1 year" },
+ ],
+ },
+ {
+ label: "Administration",
+ icon: Pill,
+ body: ["Take with food. Swallow EC tablets whole with water.", "Do not crush or chew."],
+ },
+ {
+ label: "Do not use",
+ icon: AlertTriangle,
+ tone: "danger",
+ body: [
+ "Renal insufficiency: serum creatinine >120 micromol/L (contraindicated)",
+ "Severe hepatic failure (Child-Pugh C) (contraindicated)",
+ "Pregnancy (DO NOT USE)",
+ "Breastfeeding (DO NOT USE)",
+ ],
+ },
+ {
+ label: "Populations",
+ icon: UserRound,
+ body: "Avoid in children/adolescents under 18 years and in adults over 65 years: safety and efficacy not established.",
+ },
+ {
+ label: "Key risks",
+ icon: ShieldCheck,
+ columns: [
+ { label: "GI", value: "Diarrhea, nausea, flatulence (high)" },
+ { label: "Dermatologic", value: "Rash, pruritus" },
+ { label: "Neuropsychiatric", value: "Mood swings, depression" },
+ ],
+ },
+ {
+ label: "Pearls / PK",
+ icon: FlaskConical,
+ compact: true,
+ body: "Mechanism not fully established - Not metabolized; excreted unchanged in urine - Half-life 13-28.4 h - Minimal protein binding",
+ },
+];
+
+const sideSections: SideSection[] = [
+ {
+ title: "Checks and monitoring",
+ icon: Activity,
+ items: [
+ { label: "Renal", body: "Check baseline and periodically", icon: Droplet },
+ { label: "Hepatic (severe disease)", body: "Assess if severe liver disease suspected", icon: ShieldCheck },
+ { label: "Mood / suicidality", body: "Monitor, especially early treatment", icon: Brain },
+ { label: "Adherence", body: "Reinforce adherence and support", icon: ClipboardCheck },
+ ],
+ },
+ {
+ title: "Interactions",
+ icon: ArrowLeftRight,
+ items: [
+ { label: "Diazepam, disulfiram, imipramine", body: "No major PK interactions." },
+ { label: "Naltrexone", body: "Increases acamprosate exposure; no dose adjustment required." },
+ { label: "Other psychotropics", body: "Not well studied." },
+ ],
+ },
+];
+
+type MedicationSectionId = "summary" | "dosing" | "safety" | "more";
+type ClinicalDetailView = "core" | "full";
+
+const medicationSummaryTabs: Array<{ label: string; target: MedicationSectionId }> = [
+ { label: "Summary", target: "summary" },
+ { label: "Dosing", target: "dosing" },
+ { label: "Safety", target: "safety" },
+ { label: "More", target: "more" },
+];
+
+const coreDetailLabels = new Set(["Prescribing answer", "Dosing", "Administration", "Do not use"]);
+
+function medicationSectionIdForLabel(label: string): MedicationSectionId {
+ if (label === "Dosing") return "dosing";
+ if (label === "Do not use" || label === "Key risks") return "safety";
+ if (label === "Populations" || label === "Pearls / PK") return "more";
+ return "summary";
+}
+
+function scrollToMedicationSection(section: MedicationSectionId) {
+ if (typeof document === "undefined") return;
+ const targets = Array.from(document.querySelectorAll(`[data-medication-section="${section}"]`));
+ const target = targets.find((item) => item.offsetParent !== null) ?? targets[0];
+ if (!target) return;
+ const stickyOffset = window.innerWidth < 640 ? 118 : 72;
+ const rect = target.getBoundingClientRect();
+ if (rect.top >= stickyOffset && rect.bottom <= window.innerHeight - 16) return;
+ const top = Math.max(0, rect.top + window.scrollY - stickyOffset);
+ window.scrollTo({ top, behavior: "auto" });
+}
+
+function IconTile({
+ icon: Icon,
+ tone = "teal",
+ className,
+}: {
+ icon: LucideIcon;
+ tone?: "teal" | "blue" | "slate" | "danger";
+ className?: string;
+}) {
+ return (
+
+
+
+ );
+}
+
+function StatusNotice({
+ realDataReady,
+ authUnavailable,
+ apiUnavailable,
+ setupWarning,
+}: Pick) {
+ if (realDataReady && !authUnavailable && !apiUnavailable && !setupWarning) return null;
+ const message = authUnavailable
+ ? "Private medication search is waiting for sign-in."
+ : apiUnavailable
+ ? "Medication search is using the local mockup while the API is unavailable."
+ : setupWarning || "Medication search setup is still warming up.";
+
+ return (
+
+ {message}
+
+ );
+}
+
+function QueryChip({ query }: { query: string }) {
+ return (
+
+
+ {query}
+
+ );
+}
+
+function CapabilityGrid({ compact = false }: { compact?: boolean }) {
+ return (
+
+ {medicationCapabilities.map((item, index) => {
+ const Icon = item.icon;
+ return (
+
0 && !compact && "sm:border-l sm:border-[color:var(--border)]",
+ index % 2 === 1 && "border-l border-[color:var(--border)] sm:border-l",
+ index > 1 && "border-t border-[color:var(--border)] sm:border-t-0",
+ )}
+ >
+
+
{item.description}
+
+ );
+ })}
+
+ );
+}
+
+function MedicationHome({
+ loading,
+ realDataReady,
+ authUnavailable,
+ apiUnavailable,
+ setupWarning,
+ onSuggestedSearch,
+}: Omit) {
+ return (
+
+
+
+
+
Medication search
+
Medication prescribing
+
+ Search a medication or prescribing question.
+
+
+
+
+
+
+ {medicationPrompts.map((prompt) => {
+ const Icon = prompt.icon;
+ return (
+ onSuggestedSearch(prompt.label)}
+ disabled={loading}
+ className="group flex min-h-12 w-full items-center gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-raised)] px-3 text-left text-sm font-semibold text-[color:var(--text-heading)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-chat-teal)]/35 hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-wait disabled:opacity-60"
+ >
+
+ {prompt.label}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+function resultMatchesFilter(result: MedicationResult, filter: MedicationResultFilter) {
+ if (filter === "best") return true;
+ if (filter === "indication") return result.indication.toLowerCase().includes("alcohol");
+ if (filter === "safety") return result.action.toLowerCase().includes("check") || result.action.includes("renal");
+ return result.id === "acamprosate" || result.id === "baclofen";
+}
+
+function FilterStrip({
+ activeFilter,
+ onFilterChange,
+}: {
+ activeFilter: MedicationResultFilter;
+ onFilterChange: (filter: MedicationResultFilter) => void;
+}) {
+ return (
+
+ {medicationResultFilters.map((filter) => (
+ onFilterChange(filter.id)}
+ className={cn(
+ "min-h-8 shrink-0 rounded-lg border px-2.5 text-[11px] font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:px-3 sm:text-xs",
+ activeFilter === filter.id
+ ? "border-[color:var(--clinical-chat-teal)] bg-[color:var(--clinical-chat-teal-soft)] text-[color:var(--clinical-chat-teal)]"
+ : "border-[color:var(--border)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text-heading)]",
+ )}
+ >
+ {filter.label}
+
+ ))}
+
+ );
+}
+
+function ResultToneIcon({ result }: { result: MedicationResult }) {
+ const tone = result.tone === "teal" ? "teal" : result.tone === "blue" ? "blue" : "slate";
+ return ;
+}
+
+function ResultMatchBadge({ result }: { result: MedicationResult }) {
+ return (
+
+
+ {result.match}
+
+ );
+}
+
+function DoseCeiling({ value }: { value: string }) {
+ return (
+
+
+ Ceiling
+
+ {value}
+
+ );
+}
+
+function MedicationResults({
+ query,
+ realDataReady,
+ authUnavailable,
+ apiUnavailable,
+ setupWarning,
+}: Pick<
+ MedicationPrescribingWorkspaceProps,
+ "query" | "realDataReady" | "authUnavailable" | "apiUnavailable" | "setupWarning"
+>) {
+ const [activeFilter, setActiveFilter] = useState("best");
+ const visibleMedicationResults = medicationResults.filter((result) => resultMatchesFilter(result, activeFilter));
+ const resultCount = visibleMedicationResults.length;
+
+ return (
+
+
+
+
Medication search
+
+ {resultCount} prescribing matches
+
+
+
+
+
+
+
+
+
+
+
+ Medication
+ Dose
+ Ceiling
+ Prescribing action
+ Open
+
+
+ {visibleMedicationResults.map((result) => {
+ const selected = result.id === "acamprosate";
+ const rowClassName = cn(
+ "grid w-full grid-cols-[minmax(16rem,1.15fr)_minmax(6.5rem,0.42fr)_minmax(8rem,0.48fr)_minmax(16rem,1fr)_2rem] items-center gap-2.5 px-4 py-2.5 text-left transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[color:var(--focus)]",
+ selected
+ ? "bg-[color:var(--clinical-chat-teal-soft)]/35 ring-1 ring-inset ring-[color:var(--clinical-chat-teal)]/35"
+ : result.href
+ ? "hover:bg-[color:var(--surface-subtle)]"
+ : "cursor-default opacity-80",
+ );
+ const rowContent = (
+ <>
+
+
+
+
+ {result.name}
+
+
+ {result.indication}
+
+
+
+
+
+
+
{result.dose}
+
+
+ {result.action}
+
+ {result.href ? (
+
+ ) : (
+
+ Soon
+
+ )}
+ >
+ );
+
+ if (result.href) {
+ return (
+
+ {rowContent}
+
+ );
+ }
+
+ return (
+
+ {rowContent}
+
+ );
+ })}
+
+
+
+
+ {visibleMedicationResults.map((result) => {
+ const selected = result.id === "acamprosate";
+ const cardClassName = cn(
+ "w-full rounded-lg border bg-[color:var(--surface-raised)] p-2 text-left shadow-[var(--shadow-inset)] transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
+ selected
+ ? "border-[color:var(--clinical-chat-teal)] bg-[color:var(--clinical-chat-teal-soft)]/35"
+ : result.href
+ ? "border-[color:var(--border)] hover:border-[color:var(--border-strong)]"
+ : "border-[color:var(--border)] opacity-80",
+ );
+ const cardContent = (
+
+
+
+
+
+
+ {result.name}
+
+
{result.indication}
+
+ {result.href ? (
+
+ ) : (
+
+ Soon
+
+ )}
+
+
+
+
+
+ {result.dose}
+
+
+
{result.action}
+
+
+ );
+
+ if (result.href) {
+ return (
+
+ {cardContent}
+
+ );
+ }
+
+ return (
+
+ {cardContent}
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+function DetailTile({
+ icon,
+ label,
+ value,
+ meta,
+ danger = false,
+}: {
+ icon: LucideIcon;
+ label: string;
+ value: string;
+ meta?: string;
+ danger?: boolean;
+}) {
+ return (
+
+
+
+
+
+ {label}
+
+
{value}
+ {meta ? (
+
+ {meta}
+
+ ) : null}
+
+
+
+ );
+}
+
+function DetailRowBlock({ row }: { row: DetailRow }) {
+ const Icon = row.icon;
+ const body = row.body ? (Array.isArray(row.body) ? row.body : [row.body]) : [];
+
+ return (
+
+
+
+ {row.columns ? (
+
= 4 ? "md:grid-cols-4" : "md:grid-cols-3")}>
+ {row.columns.map((column) => (
+
+
{column.label}
+
{column.value}
+ {column.meta ? (
+
+ {column.meta}
+
+ ) : null}
+
+ ))}
+
+ ) : row.tone === "danger" ? (
+
+ {body.map((item) => (
+
+
+ {item}
+
+ ))}
+
+ ) : (
+
+
+ {body.map((item) => (
+
{item}
+ ))}
+
+ {row.compact ? (
+
+ ) : null}
+
+ )}
+
+
+ );
+}
+
+function ClinicalViewToggle({
+ value,
+ onChange,
+}: {
+ value: ClinicalDetailView;
+ onChange: (value: ClinicalDetailView) => void;
+}) {
+ const options: Array<{ value: ClinicalDetailView; label: string }> = [
+ { value: "core", label: "Core" },
+ { value: "full", label: "Full" },
+ ];
+
+ return (
+
+ {options.map((option) => {
+ const active = option.value === value;
+ return (
+ onChange(option.value)}
+ className={cn(
+ "min-h-8 rounded-md px-3 text-xs font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
+ active
+ ? "bg-[color:var(--surface-raised)] text-[color:var(--clinical-chat-teal)] shadow-[var(--shadow-tight)]"
+ : "text-[color:var(--text-muted)] hover:text-[color:var(--text-heading)]",
+ )}
+ >
+ {option.label}
+
+ );
+ })}
+
+ );
+}
+
+function DetailLedger({
+ view,
+ onViewChange,
+}: {
+ view: ClinicalDetailView;
+ onViewChange: (value: ClinicalDetailView) => void;
+}) {
+ const coreRows = detailRows.filter((row) => coreDetailLabels.has(row.label));
+ const secondaryRows = detailRows.filter((row) => !coreDetailLabels.has(row.label));
+ const visibleRows = view === "core" ? coreRows : detailRows;
+
+ return (
+
+
+
+
Clinical summary
+
+ {view === "core" ? "High-yield prescribing information" : "Full medication reference"}
+
+
+
+
+
+ {visibleRows.map((row) => (
+
+ ))}
+
+ {view === "core" ? (
+
+
+
+
+ Additional populations, risks and PK
+
+
+ {secondaryRows.length} sections
+
+
+
+ {secondaryRows.map((row) => (
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
+
+function SidePanel({ section }: { section: SideSection }) {
+ const Icon = section.icon;
+ return (
+
+
+
+
{section.title}
+
+
+ {section.items.map((item) => {
+ const ItemIcon = item.icon;
+ return (
+
+
+ {ItemIcon ? (
+
+ ) : (
+
+ )}
+
+
{item.label}
+
{item.body}
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+function MedicationSummaryTabs({
+ activeSection,
+ onSectionChange,
+}: {
+ activeSection: MedicationSectionId;
+ onSectionChange: (section: MedicationSectionId) => void;
+}) {
+ return (
+
+
+ {medicationSummaryTabs.map((item) => (
+ {
+ onSectionChange(item.target);
+ scrollToMedicationSection(item.target);
+ }}
+ className={cn(
+ "relative min-h-10 px-2 py-3 text-xs font-semibold text-[color:var(--text-muted)] transition hover:text-[color:var(--text-heading)]",
+ activeSection === item.target &&
+ "text-[color:var(--clinical-chat-teal)] after:absolute after:inset-x-2 after:bottom-0 after:h-0.5 after:rounded-full after:bg-[color:var(--clinical-chat-teal)]",
+ )}
+ >
+ {item.label}
+
+ ))}
+
+
+ );
+}
+
+function MedicationBadges() {
+ return (
+
+ {["333 mg EC tablet", "PBS streamlined", "Reviewed"].map((badge, index) => (
+
+ {index === 2 ? : null}
+ {badge}
+
+ ))}
+
+ );
+}
+
+function AccessPanel() {
+ return (
+
+
+
+
Access
+
+
+ {[
+ ["Brand", "Campral"],
+ ["PBS status", "PBS streamlined"],
+ ["PBS item", "8357W"],
+ ].map(([label, value], index) => (
+
+
{label}
+ {value}
+
+ ))}
+
+
+ );
+}
+
+function SourcesDisclosure({ mobile = false }: { mobile?: boolean }) {
+ return (
+
+
+
+
+ Sources and provenance
+
+
+
+
+ Australian PI, PBS, DACAS, Australian Prescriber.
+
+
+ );
+}
+
+function MobileDetailCard({ row }: { row: DetailRow }) {
+ const Icon = row.icon;
+ const firstColumn = row.columns?.[0];
+ const summary =
+ row.columns
+ ?.map((column) => `${column.label}: ${column.value}${column.meta ? ` ${column.meta}` : ""}`)
+ .join("; ") ?? (Array.isArray(row.body) ? row.body.join(" ") : (row.body ?? ""));
+
+ return (
+
+
+
+
+ {row.label}
+
+
+ {row.label === "Dosing" && firstColumn ? summary.replace("Usual dose: ", "") : summary}
+
+
+
+ );
+}
+
+function MobileDetailList() {
+ const summaryRows = detailRows.slice(0, 5);
+ const compactRows = [
+ {
+ label: "Key risks",
+ icon: ShieldCheck,
+ section: "safety" as const,
+ body: ["GI: diarrhea, nausea, flatulence", "Dermatologic: rash, pruritus", "Neuropsychiatric: mood swings"],
+ },
+ {
+ label: "Interactions",
+ icon: ArrowLeftRight,
+ section: "more" as const,
+ body: sideSections[1].items.map((item) => `${item.label}: ${item.body}`),
+ },
+ {
+ label: "Access",
+ icon: Lock,
+ section: "more" as const,
+ body: ["Campral", "PBS streamlined", "PBS item 8357W"],
+ },
+ ];
+
+ return (
+
+
+ {summaryRows.map((row) => (
+
+ ))}
+
+
+
+ {compactRows.map(({ label, icon: Icon, section, body }) => (
+
+
+
+
+ {label}
+
+
+
+
+ {body.map((item) => (
+ {item}
+ ))}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+function MedicationDetail() {
+ const [clinicalDetailView, setClinicalDetailView] = useState("core");
+ const [activeMobileSection, setActiveMobileSection] = useState("summary");
+
+ return (
+
+
+
+ Back to medication matches
+
+
+
+
+
+
+
+
+
+ Acamprosate
+
+
+ Alcohol abstinence maintenance · GABA /
+ glutamate modulator
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {sideSections.map((section) => (
+
+ ))}
+
+
+
+
+
+ );
+}
+
+export function AcamprosateMedicationPage() {
+ return (
+
+
+
+
+
+
Medication search
+
Search
+
+
+ Clinical KB
+
+
+
+
+
+
+
+
+ Clinical KB provides evidence summaries, not medical advice. Verify clinical decisions.
+
+
+ );
+}
+
+export function MedicationPrescribingWorkspace({
+ query,
+ loading,
+ realDataReady,
+ authUnavailable,
+ apiUnavailable,
+ setupWarning,
+ onSuggestedSearch,
+}: MedicationPrescribingWorkspaceProps) {
+ const trimmedQuery = query.trim();
+
+ if (!trimmedQuery) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
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
new file mode 100644
index 0000000000..57e048a9a5
--- /dev/null
+++ b/src/lib/app-modes.ts
@@ -0,0 +1,218 @@
+import type { ClinicalQueryMode } from "@/lib/types";
+
+export type AppModeId = "answer" | "documents" | "prescribing" | "evidence" | "favourites" | "tools";
+export type SearchableAppModeId = AppModeId;
+
+export type AppModeSearchKind = "answer" | "documents" | "favourites" | "tools";
+export type AppModeResultKind = "answer" | "documents" | "favourites" | "tools";
+
+export type AppModeSearchConfig = {
+ kind: AppModeSearchKind;
+ placeholder: string;
+ inputAriaLabel: string;
+ submitIdleLabel: string;
+ submitBusyLabel: string;
+ submitAriaLabel: string;
+ emptyTitle: string;
+ readyTitle: string;
+ progressLabel: string;
+ resultKind: AppModeResultKind;
+ resultHeading: string;
+ statusLabel: string;
+ nextStep: string;
+ badgeLabel: string | null;
+ defaultQueryMode?: ClinicalQueryMode;
+};
+
+export type AppModeDefinition = {
+ id: AppModeId;
+ label: string;
+ description: string;
+ devOnly?: boolean;
+ href?: string;
+ search: AppModeSearchConfig;
+};
+
+export const appModeDefinitions = [
+ {
+ id: "answer",
+ label: "Answer",
+ description: "Source-backed clinical answer",
+ search: {
+ kind: "answer",
+ placeholder: "Ask a clinical question...",
+ inputAriaLabel: "Ask a source-backed clinical question",
+ submitIdleLabel: "Ask",
+ submitBusyLabel: "Answer",
+ submitAriaLabel: "Generate source-backed answer",
+ emptyTitle: "Enter a clinical question",
+ readyTitle: "Generate a source-backed answer",
+ progressLabel: "Searching indexed documents.",
+ resultKind: "answer",
+ resultHeading: "Answer",
+ statusLabel: "Answer",
+ nextStep: "Ask a question first",
+ badgeLabel: "?",
+ },
+ },
+ {
+ id: "documents",
+ label: "Documents",
+ description: "Search indexed PDFs and notes",
+ search: {
+ kind: "documents",
+ placeholder: "Search documents...",
+ inputAriaLabel: "Search indexed documents",
+ submitIdleLabel: "Docs",
+ submitBusyLabel: "Docs",
+ submitAriaLabel: "Find matching documents",
+ emptyTitle: "Enter a document search term",
+ readyTitle: "Find matching documents",
+ progressLabel: "Finding matching documents.",
+ resultKind: "documents",
+ resultHeading: "Document matches",
+ statusLabel: "Docs",
+ nextStep: "Review matching documents",
+ badgeLabel: null,
+ },
+ },
+ {
+ id: "prescribing",
+ label: "Medication",
+ description: "Prescribing checks and guidance",
+ href: "/?mode=prescribing",
+ search: {
+ kind: "documents",
+ placeholder: "Search medications...",
+ inputAriaLabel: "Search medication guidance",
+ submitIdleLabel: "Meds",
+ submitBusyLabel: "Meds",
+ submitAriaLabel: "Search medication guidance",
+ emptyTitle: "Enter a medication search term",
+ readyTitle: "Search medication guidance",
+ progressLabel: "Searching medication guidance.",
+ resultKind: "documents",
+ resultHeading: "Medication matches",
+ statusLabel: "Meds",
+ nextStep: "Review medication guidance",
+ badgeLabel: null,
+ defaultQueryMode: "dose_threshold_lookup",
+ },
+ },
+ {
+ id: "evidence",
+ label: "Evidence",
+ description: "Tables, quotes, images, PDFs",
+ devOnly: true,
+ href: "/mockups/evidence-option",
+ search: {
+ kind: "documents",
+ placeholder: "Search evidence...",
+ inputAriaLabel: "Search evidence across indexed sources",
+ submitIdleLabel: "Evidence",
+ submitBusyLabel: "Evidence",
+ submitAriaLabel: "Search evidence",
+ emptyTitle: "Enter an evidence search term",
+ readyTitle: "Search evidence",
+ progressLabel: "Searching evidence.",
+ resultKind: "documents",
+ resultHeading: "Evidence matches",
+ statusLabel: "Evidence",
+ nextStep: "Review matching evidence",
+ badgeLabel: null,
+ },
+ },
+ {
+ id: "favourites",
+ label: "Favourites",
+ description: "Saved sources and workflows",
+ devOnly: true,
+ href: "/?mode=favourites",
+ search: {
+ kind: "favourites",
+ placeholder: "Search or ask from favourites...",
+ inputAriaLabel: "Search saved favourites",
+ submitIdleLabel: "Faves",
+ submitBusyLabel: "Faves",
+ submitAriaLabel: "Search favourites",
+ emptyTitle: "Browse favourites",
+ readyTitle: "Search saved favourites",
+ progressLabel: "Searching favourites.",
+ resultKind: "favourites",
+ resultHeading: "Favourites",
+ statusLabel: "Favourites",
+ nextStep: "Browse saved items",
+ badgeLabel: null,
+ },
+ },
+ {
+ id: "tools",
+ label: "Tools",
+ description: "Clinical tools and applications",
+ href: "/?mode=tools",
+ search: {
+ kind: "tools",
+ placeholder: "Search tools...",
+ inputAriaLabel: "Search clinical tools and applications",
+ submitIdleLabel: "Tools",
+ submitBusyLabel: "Tools",
+ submitAriaLabel: "Search tools",
+ emptyTitle: "Browse tools",
+ readyTitle: "Search clinical tools",
+ progressLabel: "Searching tools.",
+ resultKind: "tools",
+ resultHeading: "Tools",
+ statusLabel: "Tools",
+ nextStep: "Launch a tool",
+ badgeLabel: null,
+ },
+ },
+] as const satisfies readonly AppModeDefinition[];
+
+export function appModeDefinition(modeId: AppModeId) {
+ return appModeDefinitions.find((mode) => mode.id === modeId) ?? appModeDefinitions[0];
+}
+
+export function isAppModeId(value: string | null | undefined): value is AppModeId {
+ return appModeDefinitions.some((mode) => mode.id === value);
+}
+
+export function isAppModeVisible(modeId: string, environment = process.env.NODE_ENV) {
+ const mode = appModeDefinitions.find((definition) => definition.id === modeId);
+ if (!mode) return false;
+ return !("devOnly" in mode) || !mode.devOnly || environment === "development";
+}
+
+export function visibleAppModeDefinitions(environment = process.env.NODE_ENV) {
+ return appModeDefinitions.filter((mode) => !("devOnly" in mode) || !mode.devOnly || environment === "development");
+}
+
+export function appModeSearchConfig(modeId: AppModeId) {
+ return appModeDefinition(modeId).search;
+}
+
+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);
+ if (options.focus) params.set("focus", "1");
+ if (options.run && query) params.set("run", "1");
+ return `/?${params.toString()}`;
+}
+
+export function appModeResultKind(modeId: AppModeId): AppModeResultKind {
+ return appModeSearchConfig(modeId).resultKind;
+}
+
+export function appModeQueryMode(modeId: AppModeId, queryMode: ClinicalQueryMode): ClinicalQueryMode {
+ const searchConfig = appModeSearchConfig(modeId);
+ const defaultQueryMode = "defaultQueryMode" in searchConfig ? searchConfig.defaultQueryMode : undefined;
+ return queryMode === "auto" && defaultQueryMode ? defaultQueryMode : queryMode;
+}
+
+export function isSearchableAppMode(modeId: string): modeId is SearchableAppModeId {
+ const mode = appModeDefinitions.find((definition) => definition.id === modeId);
+ if (!mode) return false;
+ const kind = mode.search.kind;
+ return kind === "answer" || kind === "documents" || kind === "favourites" || kind === "tools";
+}
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 b702413810..60e084fee0 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";
}
@@ -1254,7 +1259,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 ?? [])
@@ -1277,7 +1284,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;
@@ -1296,8 +1305,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 (
@@ -1308,7 +1321,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..fe2ed743cd 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,24 @@ 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, 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/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..29c7f97723 100644
--- a/src/lib/document-organization.ts
+++ b/src/lib/document-organization.ts
@@ -36,36 +36,170 @@ type SecondaryFacet = {
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 Bentley Group",
+ rawTags: ["rpbg"],
+ kind: "hospital",
+ evidence: [/\broyal perth bentley\b/i, /\brpbg\b/i, /\broyal perth hospital\b/i, /\brph\b/i],
+ },
+ // AKG = Armadale Kalamunda Group — 197 docs
+ {
+ canonical: "Armadale Kalamunda Group",
+ rawTags: ["akg"],
+ kind: "hospital",
+ 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", "fh"],
+ rawTags: ["fsh"],
+ kind: "hospital",
+ 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: "Peel Health Campus",
+ rawTags: ["phc"],
+ kind: "hospital",
+ evidence: [/\bpeel health campus\b/i, /\bpeel hospital\b/i, /\bphc\b/i],
+ },
+ // BHS = Bentley Health Service (subsidiary of RPBG, 1 doc)
+ {
+ canonical: "Bentley Health Service",
+ rawTags: ["bhs"],
kind: "hospital",
- evidence: [/\bfiona stanley\b/i, /\bfsh\b/i, /\bfremantle hospital\b/i],
+ evidence: [/\bbentley health service\b/i, /\bbhs\b/i],
},
+ // KEMH = King Edward Memorial Hospital — 26 docs
+ {
+ canonical: "King Edward Memorial Hospital",
+ rawTags: ["kemh"],
+ kind: "hospital",
+ evidence: [/\bking edward memorial\b/i, /\bkemh\b/i],
+ },
+ // Other major metro hospitals (not yet in this dataset but worth including)
+ {
+ 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: "Joondalup Health Campus",
+ rawTags: ["jhc", "joondalup"],
+ kind: "hospital",
+ evidence: [/\bjoondalup health campus\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: "Rockingham General Hospital",
+ rawTags: ["rgh"],
+ kind: "hospital",
+ evidence: [/\brockingham general hospital\b/i, /\brgh\b/i],
+ },
+ {
+ canonical: "Graylands / Neuropsychiatric",
+ rawTags: ["graylands", "npscu"],
+ kind: "hospital",
+ evidence: [/\bgraylands\b/i, /\bneuropsychiatric\b/i, /\bnpscu\b/i],
+ },
+
+ // ── Health services / networks ────────────────────────────────────────────
+ // EMHS = East Metropolitan Health Service — 45 docs at network level
{
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],
},
+ // SMHS = South Metropolitan Health Service (parent of FSH, Fremantle, CAMHS, RKPG)
{
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],
},
+ // 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"],
+ 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: "WA Health",
+ rawTags: ["wah", "wa health", "doh"],
+ kind: "health_service",
+ 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],
+ },
];
const secondaryTagMap = new Map([
@@ -87,12 +221,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 +380,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 +488,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 7c4159d44d..5d35f31da9 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/source-governance.ts b/src/lib/source-governance.ts
index 67649a8968..dd05720a42 100644
--- a/src/lib/source-governance.ts
+++ b/src/lib/source-governance.ts
@@ -28,7 +28,7 @@ export type GroupedSourceGovernanceWarning = {
};
export const sourceGovernanceRefusalAnswer =
- "I cannot provide a source-backed clinical answer because one or more supporting sources are marked outdated or have poor extraction quality. Review or reindex the source material before using it for clinical guidance.";
+ "I cannot provide a source-backed clinical answer from the matched evidence because one or more supporting sources have source-governance warnings that require review. Try a narrower clinical term, scope to a current approved document, or review the source warnings before using this result.";
function isLocalMetadataText(value: string) {
return /\b(?:wa|western australia|perth|north metropolitan|east metropolitan|south metropolitan|health service)\b/i.test(
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/tools.ts b/src/lib/tools.ts
index fe1f1d02d5..4aff1ef814 100644
--- a/src/lib/tools.ts
+++ b/src/lib/tools.ts
@@ -104,12 +104,11 @@ export const toolCatalog: ToolItem[] = [
title: "Medications",
description:
"Open the clinical drug guide for prescribing context, monitoring, safety checks, and medication-specific review.",
- href: "http://127.0.0.1:8081",
+ href: "/?mode=prescribing",
icon: "Pill",
category: "Clinical",
- target: "external",
+ target: "internal",
status: "online",
- openInNewTab: true,
},
{
id: "services",
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..1c37c036b9
--- /dev/null
+++ b/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql
@@ -0,0 +1,264 @@
+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;
+$$;
+
+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;
+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
+ 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..987a0aaade 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,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;
+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 (
diff --git a/tests/app-modes.test.ts b/tests/app-modes.test.ts
new file mode 100644
index 0000000000..8c8ef9644f
--- /dev/null
+++ b/tests/app-modes.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ appModeDefinitions,
+ appModeHomeHref,
+ appModeQueryMode,
+ appModeSearchConfig,
+ isAppModeId,
+ isAppModeVisible,
+ isSearchableAppMode,
+ visibleAppModeDefinitions,
+} from "@/lib/app-modes";
+
+describe("app mode search contract", () => {
+ it("requires every mode to declare its search behavior and copy", () => {
+ const ids = new Set();
+
+ for (const mode of appModeDefinitions) {
+ expect(ids.has(mode.id)).toBe(false);
+ ids.add(mode.id);
+
+ expect(mode.label).toBeTruthy();
+ expect(mode.description).toBeTruthy();
+ expect(mode.search.placeholder).toBeTruthy();
+ expect(mode.search.inputAriaLabel).toBeTruthy();
+ expect(mode.search.submitIdleLabel).toBeTruthy();
+ expect(mode.search.submitBusyLabel).toBeTruthy();
+ expect(mode.search.submitAriaLabel).toBeTruthy();
+ expect(mode.search.emptyTitle).toBeTruthy();
+ expect(mode.search.readyTitle).toBeTruthy();
+ expect(mode.search.progressLabel).toBeTruthy();
+ expect(mode.search.resultHeading).toBeTruthy();
+ }
+ });
+
+ it("keeps every declared mode searchable through the dashboard composer", () => {
+ for (const mode of appModeDefinitions) {
+ expect(isSearchableAppMode(mode.id)).toBe(true);
+ }
+ });
+
+ it("routes medication mode through document search with a medication-oriented query mode", () => {
+ expect(isSearchableAppMode("prescribing")).toBe(true);
+ expect(appModeSearchConfig("prescribing").kind).toBe("documents");
+ expect(appModeQueryMode("prescribing", "auto")).toBe("dose_threshold_lookup");
+ expect(appModeQueryMode("prescribing", "monitoring_schedule")).toBe("monitoring_schedule");
+ });
+
+ it("keeps favourites searchable inside the dashboard composer", () => {
+ const config = appModeSearchConfig("favourites");
+
+ expect(isSearchableAppMode("favourites")).toBe(true);
+ expect(config.kind).toBe("favourites");
+ expect(config.resultKind).toBe("favourites");
+ expect(config.placeholder.toLowerCase()).toContain("favourites");
+ });
+
+ it("keeps tools searchable inside the dashboard composer", () => {
+ const config = appModeSearchConfig("tools");
+
+ expect(isSearchableAppMode("tools")).toBe(true);
+ expect(config.kind).toBe("tools");
+ expect(config.resultKind).toBe("tools");
+ expect(config.placeholder.toLowerCase()).toContain("tools");
+ });
+
+ it("builds stable dashboard home URLs for shared global search chrome", () => {
+ expect(appModeHomeHref("answer")).toBe("/?mode=answer");
+ expect(appModeHomeHref("documents", { query: "lithium monitoring", run: true, focus: true })).toBe(
+ "/?mode=documents&q=lithium+monitoring&focus=1&run=1",
+ );
+ expect(appModeHomeHref("prescribing", { query: " acamprosate renal dose " })).toBe(
+ "/?mode=prescribing&q=acamprosate+renal+dose",
+ );
+ expect(appModeHomeHref("favourites")).toBe("/?mode=favourites");
+ expect(appModeHomeHref("tools", { query: " medications ", run: true, focus: true })).toBe(
+ "/?mode=tools&q=medications&focus=1&run=1",
+ );
+ });
+
+ it("keeps evidence and tools while keeping the removed profile mockup out of app mode routing", () => {
+ expect(isAppModeId("profile")).toBe(false);
+ expect(appModeDefinitions.map((mode) => mode.id)).not.toContain("profile");
+ expect(appModeDefinitions.map((mode) => mode.id)).toEqual(
+ expect.arrayContaining(["answer", "documents", "prescribing", "evidence", "favourites", "tools"]),
+ );
+ expect(visibleAppModeDefinitions("development").map((mode) => mode.id)).not.toContain("profile");
+ });
+
+ it("keeps medication visible while mock-backed modes stay out of production navigation", () => {
+ const productionModes = visibleAppModeDefinitions("production").map((mode) => mode.id);
+ const developmentModes = visibleAppModeDefinitions("development").map((mode) => mode.id);
+
+ expect(isAppModeVisible("favourites", "production")).toBe(false);
+ expect(isAppModeVisible("prescribing", "production")).toBe(true);
+ expect(isAppModeVisible("tools", "production")).toBe(true);
+ expect(productionModes).not.toContain("favourites");
+ expect(productionModes).toContain("prescribing");
+ expect(productionModes).toContain("tools");
+ expect(developmentModes).toEqual(expect.arrayContaining(["evidence", "favourites", "prescribing", "tools"]));
+ });
+});
diff --git a/tests/deep-memory.test.ts b/tests/deep-memory.test.ts
index 9f5d2fca20..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[] = [];
@@ -324,4 +389,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 39c8d75bfa..62b8096c53 100644
--- a/tests/indexing-v3-agent.test.ts
+++ b/tests/indexing-v3-agent.test.ts
@@ -20,7 +20,6 @@ const completeGateRow: CompletionGateRow = {
missing: [],
gate_passed: true,
};
-
function gateRow(overrides: Partial = {}): CompletionGateRow {
return { ...completeGateRow, ...overrides };
}
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..23ad547541 100644
--- a/tests/private-access-routes.test.ts
+++ b/tests/private-access-routes.test.ts
@@ -85,6 +85,21 @@ 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;
+ }
+
not(column: string, _operator: string, value: unknown) {
this.call.filters.push({ column, value });
return this;
@@ -392,6 +407,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)) {
@@ -437,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);
});
@@ -457,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") {
@@ -861,6 +920,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 +1169,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,
@@ -1121,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/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..1de2411fcc 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,39 @@ 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)");
+ 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",
+ );
+ 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 +164,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 +209,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 +219,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 +243,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-accessibility.spec.ts b/tests/ui-accessibility.spec.ts
index 290b69dcee..3c31898748 100644
--- a/tests/ui-accessibility.spec.ts
+++ b/tests/ui-accessibility.spec.ts
@@ -10,7 +10,7 @@ const readySetupChecks = [
];
async function mockMinimalDashboardApi(page: Page) {
- await page.route(/\/api\/setup-status$/, async (route) => {
+ await page.route("**/api/setup-status**", async (route) => {
await route.fulfill({
json: { demoMode: false, checks: readySetupChecks },
});
@@ -46,9 +46,9 @@ async function expectNoPageHorizontalOverflow(page: Page) {
}
async function expectDashboardUsable(page: Page) {
- await expect(page.getByRole("heading", { level: 1, name: "Clinical Guide" })).toBeVisible();
+ await expect(page.getByRole("heading", { level: 1, name: "Clinical Guide" })).toHaveCount(1);
await expect(page.getByRole("heading", { name: "Answer" })).toBeVisible();
- await expect(page.getByLabel("Search indexed guidelines by question or keyword")).toBeVisible();
+ await expect(page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible')).toBeVisible();
await expect(page.locator('[data-testid="scope-trigger"]:visible')).toBeVisible();
await expectNoPageHorizontalOverflow(page);
}
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index 49890d77be..05f07cf539 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -26,7 +26,7 @@ async function gotoApp(page: Page, path: string) {
}
function visibleQuestionInput(page: Page) {
- return page.locator('[aria-label="Search indexed guidelines by question or keyword"]:visible').first();
+ return page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible').first();
}
async function isVisibleWithoutThrow(locator: Locator) {
@@ -91,8 +91,30 @@ 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 page.route("**/api/setup-status**", async (route) => {
await route.fulfill({
json: { demoMode: false, checks: readySetupChecks },
});
@@ -133,7 +155,8 @@ async function fulfillAnswerResponse(route: Route, payload: unknown) {
}
async function mockDemoApi(page: Page) {
- await page.route(/\/api\/setup-status$/, async (route) => {
+ await mockLocalProjectIdentity(page);
+ await page.route("**/api/setup-status**", async (route) => {
await route.fulfill({
json: { demoMode: true, checks: readySetupChecks },
});
@@ -432,7 +455,7 @@ async function openMobileClinicalGuideMenu(page: Page) {
await expect(menu.getByRole("button", { name: "New chat" })).toBeVisible();
await expect(menu.getByPlaceholder("Search chats")).toBeVisible();
await expect(menu.getByText("Recent chats", { exact: true })).toBeVisible();
- await expect(menu.getByText("Top tools", { exact: true })).toBeVisible();
+ await expect(menu.getByText("Applications", { exact: true })).toBeVisible();
await expect(menu.getByRole("button", { name: "Guide & help" })).toBeVisible();
await expect(menu.getByRole("button", { name: "Settings" })).toBeVisible();
await expect(menu.getByText("Dr A. Khan")).toBeVisible();
@@ -449,16 +472,24 @@ async function waitForDemoDashboardReady(page: Page) {
async function openGuide(page: Page) {
const viewport = page.viewportSize();
const trigger =
- viewport && viewport.width >= 1024 ? page.getByRole("button", { name: "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();
- await trigger.click();
+ await expect(trigger).toBeEnabled();
+ await expect(async () => {
+ if (await dialog.isVisible().catch(() => false)) return;
+ await trigger.click();
+ await expect(dialog).toBeVisible({ timeout: 1_500 });
+ }).toPass({ timeout: 10_000 });
} else {
const menu = await openMobileClinicalGuideMenu(page);
await menu.getByRole("button", { name: "Guide & help" }).click();
+ await expect(dialog).toBeVisible();
}
- const dialog = page.getByRole("dialog", { name: "Clinical KB guide" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("Ask and verify")).toBeVisible();
await expect(dialog.getByText("Top source and citations")).toBeVisible();
@@ -482,6 +513,22 @@ async function openUploadDrawer(page: Page) {
return uploadDrawer;
}
+async function openDailyActions(page: Page) {
+ const trigger = page.getByRole("button", { name: "Open daily actions" });
+ const dialog = page.getByRole("dialog", { name: "Daily actions" });
+ const menu = page.getByTestId("daily-actions-menu");
+
+ await expect(trigger).toBeVisible();
+ await expect(trigger).toBeEnabled();
+ await expect(async () => {
+ if ((await dialog.isVisible().catch(() => false)) || (await menu.isVisible().catch(() => false))) return;
+ await trigger.click();
+ await expect(dialog.or(menu)).toBeVisible({ timeout: 1_500 });
+ }).toPass({ timeout: 10_000 });
+
+ return (await dialog.isVisible().catch(() => false)) ? dialog : menu;
+}
+
test.describe("Clinical KB UI smoke coverage", () => {
test.describe.configure({ timeout: 60000 });
@@ -493,7 +540,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(page.getByRole("heading", { level: 1, name: "Clinical Guide" })).toHaveCount(1);
await expect(page.getByRole("heading", { name: "Answer" })).toBeVisible();
- await expect(page.getByLabel("Search indexed guidelines by question or keyword")).toBeVisible();
+ await expect(visibleQuestionInput(page)).toBeVisible();
await expect(page.getByRole("button", { name: "Generate source-backed answer" })).toHaveText(/^\s*Ask\s*$/);
const headerHeight = await page.locator("#search").evaluate((element) => element.getBoundingClientRect().height);
expect(headerHeight).toBeLessThanOrEqual(viewport.width >= 640 ? 185 : 180);
@@ -510,17 +557,14 @@ test.describe("Clinical KB UI smoke coverage", () => {
}
if (viewport.width < 640) {
const dailyActionsTrigger = page.getByRole("button", { name: "Open daily actions" });
- await dailyActionsTrigger.click();
- const dailyActions = page.getByRole("dialog", { name: "Daily actions" });
- await expect(dailyActions).toBeVisible();
- await expect(dailyActions.getByTestId("daily-actions-sheet")).toBeVisible();
+ const dailyActions = await openDailyActions(page);
const addDocument = dailyActions.getByRole("button", { name: "Add document" });
await expect(addDocument).toBeVisible();
- await expect(dailyActions.getByRole("button", { name: "Clinical tools" })).toBeVisible();
+ await expect(dailyActions.getByRole("button", { name: "Tools" })).toBeVisible();
await expectMinTouchTarget(addDocument);
await expect(page.getByRole("dialog", { name: "Clinical KB guide" })).toHaveCount(0);
await page.keyboard.press("Escape");
- await expect(dailyActions).toHaveCount(0);
+ await expect(dailyActions).toBeHidden();
await expect(dailyActionsTrigger).toBeFocused();
}
await expectNoPageHorizontalOverflow(page);
@@ -556,7 +600,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
});
await gotoApp(page, "/");
- const questionInput = page.getByLabel("Search indexed guidelines by question or keyword");
+ const questionInput = visibleQuestionInput(page);
await questionInput.fill("lithium monitoring");
await expect(page.getByRole("button", { name: "Generate source-backed answer" })).toBeDisabled();
await expect(page.getByTestId("answer-grounding-chip")).toHaveCount(0);
@@ -566,20 +610,43 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expectNoPageHorizontalOverflow(page);
});
+ test("desktop daily actions close when clicking outside or opening scope", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await mockPrivateUnauthenticatedApi(page);
+ await gotoApp(page, "/");
+ await waitForDemoDashboardReady(page);
+
+ const dailyActionsTrigger = page.getByRole("button", { name: "Open daily actions" });
+ const dailyActionsMenu = page.getByTestId("daily-actions-menu");
+
+ // 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");
+
+ // Second open — verify closing via scope trigger.
+ await openDailyActions(page);
+ await scopeTrigger(page).click();
+
+ await expect(dailyActionsMenu).toHaveCount(0);
+ const scopePopover = page.locator('[data-testid="scope-command-popover"]:visible');
+ await expect(scopePopover).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(scopePopover).toBeHidden();
+ await expectNoPageHorizontalOverflow(page);
+ });
+
test("demo answer flow reaches a source-backed answer", async ({ browserName, page }) => {
await page.setViewportSize({ width: 390, height: 820 });
await mockDemoApi(page);
await gotoApp(page, "/");
await waitForDemoDashboardReady(page);
- const questionInput = page.getByLabel("Search indexed guidelines by question or keyword");
- await expect(questionInput).toBeEnabled();
await expect(scopeTrigger(page)).toBeVisible();
const question = "What clozapine monitoring items are shown in the table image?";
- await questionInput.click();
- await questionInput.pressSequentially(question);
+ const questionInput = await fillVisibleQuestionInput(page, question);
await expect(questionInput).toHaveValue(question);
- await expect(page.getByRole("button", { name: "Generate source-backed answer" })).toBeEnabled();
await page.getByRole("button", { name: "Generate source-backed answer" }).click();
await expect(page.getByRole("button", { name: "Ask a question" })).toHaveCount(0);
@@ -680,10 +747,32 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(page.getByTestId("mobile-section-fab-menu")).toHaveCount(0);
await expectDomIntegrity(page, { mobileNav: true });
- const clinicalNotesDrawer = page.locator("details").filter({ hasText: "Clinical notes 1" }).first();
- const clinicalNotesSummary = clinicalNotesDrawer.locator("summary");
- await expect(clinicalNotesSummary.getByText("Safety notes", { exact: true })).toBeVisible();
- await expect(clinicalNotesSummary.getByText(/Monitoring, safety, escalation/)).toBeHidden();
+ const clinicalNotesTrigger = page.locator("#answer-clinical-notes-drawer-mobile-trigger");
+ await expect(clinicalNotesTrigger).toBeVisible();
+ await expect(clinicalNotesTrigger).toContainText("Clinical notes");
+ await expect(clinicalNotesTrigger).toContainText("Source-backed");
+ await expectMinTouchTarget(clinicalNotesTrigger);
+ await clinicalNotesTrigger.click();
+ const clinicalNotesSheet = page.getByRole("dialog", { name: "Clinical notes" });
+ await expect(clinicalNotesSheet).toBeVisible();
+ await expect(clinicalNotesSheet.getByTestId("clinical-notes-checklist")).toBeVisible();
+ await expect(clinicalNotesSheet.getByRole("tab", { name: /Safety/ })).toBeVisible();
+ await expect(clinicalNotesSheet.getByRole("tab", { name: /Monitor/ })).toBeVisible();
+ const clinicalNotesTableToggle = clinicalNotesSheet.getByRole("tab", { name: /Table/i });
+ await expect(clinicalNotesTableToggle).toBeVisible();
+ await expect(clinicalNotesSheet.getByText("Table details")).toHaveCount(0);
+ await expect(clinicalNotesSheet.getByRole("table")).toHaveCount(0);
+ expect(await clinicalNotesSheet.getByTestId("clinical-note-row").count()).toBeGreaterThan(0);
+ await expect(clinicalNotesSheet.getByText("Safety checklist")).toBeVisible();
+ await clinicalNotesTableToggle.click();
+ await expect(clinicalNotesSheet).toHaveCount(0);
+ const tablePopoutSheet = page.getByRole("dialog", { name: "Evidence" });
+ await expect(tablePopoutSheet).toBeVisible();
+ await expect(tablePopoutSheet.getByTestId("mobile-evidence-tab-tables")).toHaveAttribute("aria-selected", "true");
+ await expect(tablePopoutSheet.getByTestId("mobile-evidence-panel-tables")).toBeVisible();
+ await expect(tablePopoutSheet.getByRole("table")).toHaveCount(0);
+ await page.keyboard.press("Escape");
+ await expect(tablePopoutSheet).toHaveCount(0);
const evidenceDrawer = page.locator("#answer-evidence-drawer-mobile-trigger");
await expect(evidenceDrawer).toBeVisible();
@@ -736,6 +825,9 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(page.getByText("Top source detail")).toHaveCount(0);
await expect(page.getByText("Retrieval details")).toHaveCount(0);
+ await page.keyboard.press("Escape");
+ await expect(evidenceSheet).toHaveCount(0);
+ await expect(evidenceDrawer).toBeFocused();
await scopeTrigger(page).click();
const scopePopover = page.locator('[data-testid="scope-command-popover"]:visible');
@@ -834,6 +926,54 @@ test.describe("Clinical KB UI smoke coverage", () => {
});
}
+ test("favourites live route uses dashboard chrome, filtering, and new chat reset", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await mockDemoApi(page);
+ await gotoApp(page, "/?mode=favourites");
+
+ const globalSearchInput = visibleQuestionInput(page);
+ const searchFavourites = page.getByRole("button", { name: "Search favourites" });
+ await expect(page.getByRole("button", { name: "Current app mode: Favourites" })).toBeVisible();
+ await expect(page.getByTestId("favourites-hub")).toBeVisible();
+ await expect(globalSearchInput).toHaveAttribute("placeholder", "Search or ask from favourites...");
+
+ await globalSearchInput.click();
+ await page.keyboard.type("lithium");
+ await expect(searchFavourites).toBeEnabled();
+ await searchFavourites.click();
+
+ await expect(page.getByTestId("favourites-hub")).toContainText("Filter: lithium");
+ await expect(page.getByTestId("favourites-hub")).toContainText("Lithium monitoring guideline");
+ await expect(page.locator("body")).not.toContainText("Clinical KB favourites mockup");
+
+ await page.getByRole("button", { name: "Start a new chat" }).click();
+ await expect(page).toHaveURL(/\/\?mode=answer&focus=1$/);
+ await waitForDemoDashboardReady(page);
+ await expect(page.getByRole("button", { name: "Current app mode: Answer" })).toBeVisible();
+ await expect(page.getByTestId("global-search-input")).toBeFocused();
+ });
+
+ test("prescribing workflow uses in-app medication routes", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await mockDemoApi(page);
+ await gotoApp(page, "/?mode=prescribing&q=acamprosate%20renal%20dose");
+
+ const globalSearchInput = page.getByTestId("global-search-input");
+ await expect(page.getByRole("button", { name: "Current app mode: Medication" })).toBeVisible();
+ await expect(globalSearchInput).toHaveAttribute("placeholder", "Search medications...");
+ await expect(globalSearchInput).toHaveValue("acamprosate renal dose");
+
+ const acamprosateResult = page.getByTestId("medication-result-acamprosate-desktop");
+ await expect(acamprosateResult).toHaveAttribute("href", "/medications/acamprosate");
+ await acamprosateResult.click();
+ await expect(page).toHaveURL(/\/medications\/acamprosate$/);
+ await expect(page.getByTestId("acamprosate-medication-page")).toBeVisible();
+
+ await gotoApp(page, "/mockups/medication-prescribing");
+ await expect(page).toHaveURL(/\/medications\/acamprosate$/);
+ await expect(page.getByTestId("acamprosate-medication-page")).toBeVisible();
+ });
+
test("document search mode lists matching documents and scope actions", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 820 });
await mockDemoApi(page);
@@ -847,15 +987,21 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(page.getByTestId("document-search-workspace")).toBeVisible();
await expect(visibleQuestionInput(page)).toBeVisible();
await expect(page.getByTestId("document-search-empty-state")).toBeVisible();
- await expect(page.getByText(`${demoDocuments.length} sources indexed`)).toBeVisible();
+ await expect(page.getByRole("region", { name: "Start here" })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Recent documents/i })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Browse library/i })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Open a source PDF/i })).toBeVisible();
+ await expect(page.getByRole("region", { name: "Explore document parts" })).toHaveCount(0);
+ await expect(page.getByRole("region", { name: "Suggested searches" })).toHaveCount(0);
+ await expect(page.getByRole("heading", { name: "Recently indexed" })).toHaveCount(0);
+ await expect(page.locator('a[href^="/documents/"]')).toHaveCount(0);
await expect(page.getByRole("button", { name: /Resume Lithium monitoring guideline/i })).toHaveCount(0);
await expect(page.getByRole("region", { name: "Document shortcuts" })).toHaveCount(0);
- await expect(page.getByRole("region", { name: "Suggested searches" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "monitoring", exact: true })).toHaveCount(0);
await expect(page.getByText("Source library workspace")).toHaveCount(0);
await expect(page.getByText("Document display")).toHaveCount(0);
- const questionInput = page.getByLabel("Search indexed guidelines by question or keyword");
+ const questionInput = visibleQuestionInput(page);
await questionInput.fill("lithium monitoring");
await page.getByRole("button", { name: "Find matching documents" }).click();
@@ -879,6 +1025,19 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expectNoPageHorizontalOverflow(page);
});
+ test("tools mode searches the existing applications registry inside the dashboard", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await mockPrivateUnauthenticatedApi(page);
+ await gotoApp(page, "/?mode=tools&q=medications&focus=1&run=1");
+
+ await expect(page.getByRole("button", { name: "Current app mode: Tools" })).toBeVisible();
+ await expect(page.getByPlaceholder("Search tools...")).toHaveValue("medications");
+ await expect(page.getByTestId("tools-hub")).toBeVisible();
+ await expect(page.getByTestId("tool-mode-result-medications")).toContainText("Medications");
+ await expect(page.getByLabel("Launch Medications")).toHaveAttribute("href", "/?mode=prescribing");
+ await expectNoPageHorizontalOverflow(page);
+ });
+
test("search regressions avoid fetch errors and open viewer hits", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await mockDemoApi(page);
@@ -886,7 +1045,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
await waitForDemoDashboardReady(page);
await switchToDocumentSearchMode(page);
- const questionInput = page.getByLabel("Search indexed guidelines by question or keyword");
+ const questionInput = visibleQuestionInput(page);
await questionInput.fill("what is the best coffee machine for my kitchen");
await page.getByRole("button", { name: "Find matching documents" }).click();
@@ -999,7 +1158,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();
@@ -1017,7 +1179,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
});
test("document viewer failed preview exposes retry recovery", async ({ page }) => {
- await page.route(/\/api\/setup-status$/, async (route) => {
+ await page.route("**/api/setup-status**", async (route) => {
await route.fulfill({ json: { demoMode: true, checks: readySetupChecks } });
});
await page.route(/\/api\/documents\/([^/]+)(?:\?.*)?$/, async (route) => {
@@ -1102,7 +1264,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
await page.setViewportSize({ width: 414, height: 820 });
await mockPrivateUnauthenticatedApi(page);
await gotoApp(page, "/");
- await expect(page.getByLabel("Search indexed guidelines by question or keyword")).toBeVisible();
+ await expect(visibleQuestionInput(page)).toBeVisible();
const uploadDrawer = await openUploadDrawer(page);
diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts
index 34021b9eec..5352df87cb 100644
--- a/tests/ui-stress.spec.ts
+++ b/tests/ui-stress.spec.ts
@@ -173,7 +173,7 @@ async function mockStressData(page: Page) {
await page.route(/\/api\/ingestion\/batches(?:\?.*)?$/, async (route) => {
await route.fulfill({ json: { batches: [], demoMode: true } });
});
- await page.route(/\/api\/setup-status(?:\?.*)?$/, async (route) => {
+ await page.route("**/api/setup-status**", async (route) => {
await route.fulfill({
json: {
demoMode: true,
@@ -206,6 +206,22 @@ async function expectNoPageHorizontalOverflow(page: Page) {
expect(overflow).toBeLessThanOrEqual(2);
}
+async function openDailyActions(page: Page) {
+ const trigger = page.getByRole("button", { name: "Open daily actions" });
+ const dialog = page.getByRole("dialog", { name: "Daily actions" });
+ const menu = page.getByTestId("daily-actions-menu");
+
+ await expect(trigger).toBeVisible();
+ await expect(trigger).toBeEnabled();
+ await expect(async () => {
+ if ((await dialog.isVisible().catch(() => false)) || (await menu.isVisible().catch(() => false))) return;
+ await trigger.click();
+ await expect(dialog.or(menu)).toBeVisible({ timeout: 1_500 });
+ }).toPass({ timeout: 10_000 });
+
+ return (await dialog.isVisible().catch(() => false)) ? dialog : menu;
+}
+
test.describe("Clinical KB long-content stress coverage", () => {
for (const viewport of [
{ name: "mobile", width: 320, height: 740 },
@@ -217,14 +233,13 @@ test.describe("Clinical KB long-content stress coverage", () => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
- await page.getByRole("button", { name: "Open daily actions" }).click();
- await page.getByRole("button", { name: "Add document" }).click();
- await expect(
- page
- .locator("#sources")
- .getByText(viewport.name === "mobile" ? "24 documents" : "24 indexed documents available")
- .first(),
- ).toBeVisible();
+ 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");
+ await expect(uploadSurface.getByText("24 indexed").first()).toBeVisible();
const closeUploadSheet = page.getByRole("button", { name: "Close Upload and indexing" });
if (await closeUploadSheet.isVisible().catch(() => false)) {
await closeUploadSheet.click();
@@ -235,10 +250,12 @@ test.describe("Clinical KB long-content stress coverage", () => {
const scopeContainer = page.getByTestId("scope-command-popover");
await expect(scopeContainer).toBeVisible();
await expect(
- scopeContainer.getByText("Type to filter 24 documents. Selected documents stay pinned here."),
+ scopeContainer.getByText(/Type to filter 24 (loaded )?documents\. Selected documents stay pinned here\./),
).toBeVisible();
await expect(
- scopeContainer.getByText("24 documents available. Type a title or file name to narrow the list."),
+ scopeContainer.getByText(
+ /(?:24 documents available|24 available documents)\. Type a title or file name to narrow the (?:loaded )?list\./,
+ ),
).toBeVisible();
const scopeFilter = scopeContainer.locator('[data-testid="document-scope-filter"]');
await expect(scopeFilter).toBeVisible();
@@ -268,7 +285,7 @@ test.describe("Clinical KB long-content stress coverage", () => {
await expect(page.getByRole("button", { name: "Current app mode: Answer" })).toBeVisible();
}
await page
- .locator('[aria-label="Search indexed guidelines by question or keyword"]:visible')
+ .locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible')
.first()
.fill("Show all stress citations and source cards");
await page.locator('[aria-label="Generate source-backed answer"]:visible').first().click();
diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts
index 02948f2567..296b03f540 100644
--- a/tests/ui-tools.spec.ts
+++ b/tests/ui-tools.spec.ts
@@ -1,7 +1,7 @@
import { expect, test, type Page } from "playwright/test";
-async function gotoTools(page: Page) {
- await page.goto("/tools", { waitUntil: "domcontentloaded" });
+async function gotoLauncher(page: Page, path = "/applications") {
+ await page.goto(path, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
}
@@ -14,25 +14,81 @@ async function expectNoPageHorizontalOverflow(page: Page) {
expect(overflow).toBeLessThanOrEqual(2);
}
-test.describe("Clinical KB tools launcher", () => {
+test.describe("Clinical KB applications launcher", () => {
test.describe.configure({ timeout: 60_000 });
for (const viewport of [
{ name: "mobile", width: 390, height: 820 },
{ name: "desktop", width: 1280, height: 900 },
] as const) {
- test(`tools launcher is usable at ${viewport.name}`, async ({ page }) => {
+ test(`applications launcher is usable at ${viewport.name}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
- await gotoTools(page);
-
- await expect(page.getByRole("heading", { level: 1, name: "Open the right clinical tool." })).toBeVisible();
- await expect(page.getByRole("heading", { name: "All clinical tools" })).toBeVisible();
- await expect(page.getByLabel("Return to Clinical KB dashboard")).toBeVisible();
- await expect(page.getByLabel("Launch priority tool Formulation")).toBeVisible();
- await expect(page.getByLabel("Launch Formulation")).toBeVisible();
- await expect(page.getByLabel("Launch Psychiatry Notes")).toBeVisible();
- await expect(page.getByRole("link", { name: "Launchers" })).toHaveAttribute("href", "#launchers");
+ await gotoLauncher(page);
+
+ await expect(page.getByRole("heading", { level: 1, name: "Applications" })).toBeVisible();
+ await expect(page.getByRole("region", { name: "Pinned applications" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "All applications" })).toBeVisible();
+ if (viewport.name === "mobile") {
+ await expect(page.getByTestId("selected-application-panel")).toBeHidden();
+ await page.getByTestId("mobile-application-row-differential-diagnosis").click();
+ const selectedSheet = page.getByRole("dialog", { name: "Selected application" });
+ await expect(selectedSheet).toBeVisible();
+ await expect(page.getByTestId("selected-application-sheet-panel")).toContainText("Differential Diagnosis");
+ const mobileLaunchLink = page
+ .getByTestId("selected-application-sheet-panel")
+ .getByLabel("Launch Differential Diagnosis");
+ await expect(mobileLaunchLink).toBeVisible();
+ await expect(mobileLaunchLink).toHaveAttribute("href", "http://127.0.0.1:53375");
+ await page.getByLabel("Close selected application").click();
+ await expect(selectedSheet).toBeHidden();
+ } else {
+ await expect(page.getByTestId("selected-application-panel")).toContainText("Formulation");
+ const desktopLaunchLink = page.getByTestId("selected-application-panel").getByLabel("Launch Formulation");
+ await expect(desktopLaunchLink).toBeVisible();
+ await expect(desktopLaunchLink).toHaveAttribute("href", "http://localhost:53210");
+ }
+ await expect(page.getByLabel("Current app mode: Applications")).toBeVisible();
+ await expect(page.getByPlaceholder("Search applications...")).toBeVisible();
+ await expect(page.getByLabel("Open selected application")).toBeVisible();
await expectNoPageHorizontalOverflow(page);
});
}
+
+ test("launcher links point to the expected applications", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await gotoLauncher(page);
+
+ await expect(page.locator('a[aria-label="Launch Formulation"]').first()).toHaveAttribute(
+ "href",
+ "http://localhost:53210",
+ );
+ await expect(page.locator('a[aria-label="Launch Differential Diagnosis"]').first()).toHaveAttribute(
+ "href",
+ "http://127.0.0.1:53375",
+ );
+ const medicationLink = page.locator('a[aria-label="Launch Medication Prescribing"]').first();
+ await expect(medicationLink).toHaveAttribute("href", "/?mode=prescribing");
+ await expect(medicationLink).not.toHaveAttribute("target", "_blank");
+ await expect(page.locator('a[aria-label="Launch Documents"]').first()).toHaveAttribute("href", "/?mode=documents");
+ await expect(page.locator('a[aria-label="Launch Favourites"]').first()).toHaveAttribute(
+ "href",
+ "/?mode=favourites",
+ );
+ await expect(page.locator('a[aria-label="Launch Clinical KB Search"]').first()).toHaveAttribute(
+ "href",
+ "/?mode=answer",
+ );
+ });
+
+ test("search and filters reduce visible application rows without overflow", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await gotoLauncher(page);
+
+ await page.getByLabel("Search applications").fill("specifier");
+
+ await expect(page.getByTestId("application-row-specifiers")).toBeVisible();
+ await expect(page.getByTestId("application-row-formulation")).toBeHidden();
+ await expect(page.getByText("Showing 1 to 1 of 8 applications")).toBeVisible();
+ await expectNoPageHorizontalOverflow(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 a9095a9dcf..3b88e56c79 100644
--- a/tests/worker-visual-capture.test.ts
+++ b/tests/worker-visual-capture.test.ts
@@ -7,32 +7,46 @@ 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('"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", () => {
- 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/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;
diff --git a/worker/main.ts b/worker/main.ts
index aa0ad4aca6..20c494efba 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,59 @@ 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 replacePageRows(args.documentId, args.pages);
+ await upsertIndexQuality(args.quality);
+ await deleteStaleIndexGenerationRows(args.documentId, args.indexGenerationId);
+}
+
+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 +432,54 @@ async function insertPages(documentId: string, extracted: ExtractedDocument) {
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",
+ });
+ if (error) throw supabaseStageError("upsert document_index_quality", error);
+}
+
function hashBytes(bytes: Buffer) {
return createHash("sha256").update(bytes).digest("hex");
}
@@ -662,7 +753,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;
@@ -860,7 +956,7 @@ async function uploadAndCaptionImages(job: JobRow, extracted: ExtractedDocument,
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 });
@@ -888,6 +984,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 +1188,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 +1198,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 +1268,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 +1288,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 +1316,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 +1343,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 +1429,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 +1453,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 +1476,42 @@ 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 coreAgentMessage = "Core index committed; enrichment pending.";
+ 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,
+ 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({
+ 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 +1557,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 +1581,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,11 +1607,16 @@ 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(),
}
- : {}),
+ : {
+ 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,
};
@@ -1548,7 +1658,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 +1666,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 +1675,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);
}