diff --git a/.gitleaksignore b/.gitleaksignore index 8b3bd81875..16fe1ab80b 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -8,3 +8,5 @@ # generic-api-key rule on historical commit content scanned in PR history. 27121756b10df6d93841ebf48b0f1991e0144a1f:data/medications-snapshot.json:generic-api-key:22253 27121756b10df6d93841ebf48b0f1991e0144a1f:data/medications-snapshot.json:generic-api-key:47690 +b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic-api-key:21520 +b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic-api-key:46330 diff --git a/scripts/eval-quality.ts b/scripts/eval-quality.ts index 0bde64fafb..0c1749ceb8 100644 --- a/scripts/eval-quality.ts +++ b/scripts/eval-quality.ts @@ -45,6 +45,7 @@ type EvalQualityArgs = { retrievalOnly: boolean; ragOnly: boolean; skipPreflight: boolean; + forceEmbedding: boolean; }; export type RagQualityResult = { @@ -150,6 +151,7 @@ function parseArgs(argv: string[]): EvalQualityArgs { retrievalOnly: false, ragOnly: false, skipPreflight: false, + forceEmbedding: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -176,6 +178,10 @@ function parseArgs(argv: string[]): EvalQualityArgs { args.skipPreflight = true; continue; } + if (token === "--force-embedding") { + args.forceEmbedding = true; + continue; + } const value = argv[index + 1]; if (!value || value.startsWith("--")) throw new Error(`Missing value for ${token}`); @@ -476,6 +482,11 @@ export function buildEvalQualityReport(args: { `retrieval content_recall_at_5 ${retrievalSummary.content_recall_at_5} below ${qualityThresholds.retrievalContentRecallAt5}`, ); } + if (retrievalSummary.force_embedding_failure_count > 0) { + thresholdFailures.push( + `retrieval force_embedding_failure_count ${retrievalSummary.force_embedding_failure_count} above 0`, + ); + } if (governance.stale_rate > qualityThresholds.staleTopResultRate) { thresholdFailures.push( `top-result stale_rate ${governance.stale_rate} above ${qualityThresholds.staleTopResultRate}`, @@ -664,6 +675,8 @@ ${markdownTable([ ## Retrieval Decision Metrics ${markdownTable([ + ["Force-embedding cases", retrieval.force_embedding_case_count], + ["Force-embedding failures", retrieval.force_embedding_failure_count], ["Embedding skipped rate", retrieval.embedding_skipped_rate], ["Median text candidate budget", retrieval.median_text_candidate_budget], ["Second-stage rerank rate", retrieval.second_stage_rerank_rate], @@ -728,6 +741,7 @@ async function runRetrievalQualityCases(args: { ownerId?: string; limit?: number; query?: string; + forceEmbedding?: boolean; supabase: Awaited>; }) { const [{ searchChunksWithTelemetry }, capturedCases] = await Promise.all([ @@ -756,7 +770,7 @@ async function runRetrievalQualityCases(args: { topK: retrievalLimitForGoldenCase(testCase), minSimilarity: 0.12, skipCache: true, - forceEmbedding: testCase.forceEmbedding, + forceEmbedding: testCase.forceEmbedding || args.forceEmbedding, }), ); const latencyMs = diff --git a/scripts/eval-retrieval.ts b/scripts/eval-retrieval.ts index edeb89564d..2a46d96f25 100644 --- a/scripts/eval-retrieval.ts +++ b/scripts/eval-retrieval.ts @@ -47,6 +47,7 @@ type EvalArgs = { export type GoldenRetrievalResult = { id: string; query: string; + forceEmbedding: boolean; expectedQueryClass: string; actualQueryClass: string | null; expectedDocumentSubstrings: string[]; @@ -516,6 +517,11 @@ export function evaluateGoldenRetrievalCase(args: { const tableEvidenceFoundAtK = hasTableEvidence(args.results, topK); const actualQueryClass = args.telemetry.query_class ?? null; const failures: string[] = []; + const vectorLayerCount = Object.entries(args.telemetry.retrieval_layer_counts ?? {}).reduce( + (sum, [layer, count]) => + ["embedding_fields", "index_units", "hybrid_vector", "vector_fallback"].includes(layer) ? sum + count : sum, + 0, + ); const hitAtK = documentHitsAtK.missing.length === 0 && contentHitsAtK.missing.length === 0 && @@ -533,10 +539,23 @@ export function evaluateGoldenRetrievalCase(args: { if (args.testCase.expectTableEvidence && !tableEvidenceFound) { failures.push("expected table evidence in top 5"); } + if (args.testCase.forceEmbedding) { + if (args.telemetry.embedding_skipped) failures.push("forceEmbedding expected embedding to run"); + if (args.telemetry.retrieval_strategy === "search_cache") failures.push("forceEmbedding served search cache"); + if ( + args.telemetry.retrieval_strategy === "text_fast_path" || + args.telemetry.retrieval_strategy === "document_lookup_fast_path" + ) { + failures.push(`forceEmbedding returned lexical strategy ${args.telemetry.retrieval_strategy}`); + } + if (args.telemetry.coverage_gate_decision === "accepted") failures.push("forceEmbedding returned coverage gate"); + if (vectorLayerCount <= 0) failures.push("forceEmbedding found no vector-layer candidates"); + } return { id: args.testCase.id, query: args.testCase.query, + forceEmbedding: args.testCase.forceEmbedding ?? false, expectedQueryClass: args.testCase.expectedQueryClass, actualQueryClass, expectedDocumentSubstrings: args.testCase.expectedDocumentSubstrings, @@ -612,6 +631,7 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[] } return counts; }, {}); + const forceEmbeddingResults = results.filter((result) => result.forceEmbedding); return { case_count: results.length, document_recall_at_5: Number( @@ -647,6 +667,8 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[] embedding_skip_reason_counts: embeddingSkipReasonCounts, text_fast_path_reason_counts: textFastPathReasonCounts, retrieval_layer_counts: layerCounts, + force_embedding_case_count: forceEmbeddingResults.length, + force_embedding_failure_count: forceEmbeddingResults.filter((result) => result.failures.length > 0).length, median_text_candidate_budget: percentile(textCandidateBudgets, 50), second_stage_rerank_rate: Number( (results.filter((result) => result.secondStageRerankUsed).length / Math.max(results.length, 1)).toFixed(4), @@ -723,6 +745,8 @@ function printHumanSummary(summary: ReturnType { throw new PublicApiError("Invalid upload form data.", 400, { code: "invalid_form_data", @@ -107,7 +112,7 @@ export async function POST(request: Request) { const documentId = randomUUID(); const safeName = file.name.replace(/[^\w.\-() ]+/g, "_"); - const storagePath = `${user.id}/documents/${documentId}/${safeName}`; + const storagePath = `${uploadOwnerId}/documents/${documentId}/${safeName}`; const buffer = Buffer.from(await file.arrayBuffer()); // The declared MIME type is client-supplied; verify the real byte signature // before persisting a clinical document. @@ -117,7 +122,7 @@ export async function POST(request: Request) { const { data: duplicate, error: duplicateError } = await adminSupabase .from("documents") .select("id,title,file_name,status,page_count,chunk_count,image_count,created_at") - .eq("owner_id", user.id) + .eq("owner_id", uploadOwnerId) .eq("content_hash", contentHash) .maybeSingle(); @@ -147,7 +152,7 @@ export async function POST(request: Request) { }; const namePlan = await planDocumentName({ supabase: namingSupabase, - ownerId: user.id, + ownerId: uploadOwnerId, fileName: file.name, requestedTitle: uploadMetadata.title, contentHash, @@ -160,7 +165,7 @@ export async function POST(request: Request) { .from("documents") .insert({ id: documentId, - owner_id: user.id, + owner_id: uploadOwnerId, title, description, file_name: file.name, @@ -178,7 +183,7 @@ export async function POST(request: Request) { review_date: null, uploaded_at: uploadedAt, indexed_at: null, - uploaded_by: user.id, + uploaded_by: uploadOwnerId, original_file_name: namePlan.originalFileName, original_title: namePlan.originalTitle, smart_title_base: namePlan.baseTitle, @@ -202,7 +207,7 @@ export async function POST(request: Request) { insertedDocumentOwnerId = null; return duplicateUploadResponse({ supabase, - ownerId: user.id, + ownerId: uploadOwnerId, contentHash, storagePath: uploadedPath, }); @@ -210,7 +215,7 @@ export async function POST(request: Request) { throw new Error(documentError.message); } insertedDocumentId = documentId; - insertedDocumentOwnerId = user.id; + insertedDocumentOwnerId = uploadOwnerId; const { data: job, error: jobError } = await supabase .from("ingestion_jobs") @@ -230,7 +235,7 @@ export async function POST(request: Request) { .from("documents") .delete() .eq("id", documentId) - .eq("owner_id", user.id); + .eq("owner_id", uploadOwnerId); if (rollbackDocumentError) { throw new Error( `Failed to enqueue ingestion job: ${jobError.message}; rollback failed: ${rollbackDocumentError.message}`, @@ -242,7 +247,7 @@ export async function POST(request: Request) { } await writeAuditLog(supabase, { - ownerId: user.id, + ownerId: uploadOwnerId, action: "document_upload", resourceType: "document", resourceId: documentId, diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2545d2b249..a2c6851379 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1,18 +1,15 @@ -"use client"; +"use client"; -import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import dynamic from "next/dynamic"; import { AlertCircle, Bell, BookOpen, - CheckCircle2, ChevronDown, ChevronRight, CircleUserRound, Clock3, - Copy, ExternalLink, FileImage, FileText, @@ -21,7 +18,6 @@ import { HelpCircle, Heart, Keyboard, - Layers, ListChecks, Loader2, LogOut, @@ -29,7 +25,6 @@ import { LockKeyhole, Palette, PanelTop, - Plus, Quote, RefreshCw, Search, @@ -48,66 +43,38 @@ import { import { type CSSProperties, type FormEvent, - type RefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; -import { AccessibleTable } from "@/components/AccessibleTable"; -import { - DocumentOrganizationBadges, - documentDisplayTitle, - documentOrganizationProfile, -} from "@/components/DocumentOrganizationBadges"; -import { DocumentTagCloud } from "@/components/DocumentTagCloud"; -import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions"; +import { type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; -import { formatCompactCitationLabel } from "@/lib/citations"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; -import { isLocalNoAuthMode } from "@/lib/env"; +import { isDeployedClinicalKb } from "@/lib/deployed-app"; +import { isLocalNoAuthMode, publicUploadsEnabled } from "@/lib/env"; import { appBackdrop, answerSurface, - chatMicroAction, - clinicalDivider, cn, - EmptyState, - fieldControlPlain, fieldControlWithIcon, fieldIcon, floatingControl, - iconTilePremium, - metadataPill, - panelSubtle, primaryControl, - SourceProvenance, - SourceStatusBadge, - sourceCard, - subtleStatusPill, - tableCard, - tableCardHeader, - tableMicroActionRow, textMuted, - toneDanger, - toneInfo, - toneNeutral, toneSuccess, toneWarning, } from "@/components/ui-primitives"; import { useAuthSession } from "@/lib/supabase/client"; -import { SafeBoldText } from "@/components/SafeBoldText"; import { Sheet } from "@/components/ui/sheet"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface"; import { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results"; -import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; -import { StatusBadge } from "@/components/clinical-dashboard/badges"; import { type SidebarIdentity, deriveSidebarIdentity, @@ -129,42 +96,22 @@ import { import { GuideDialog, GuideTrigger, - SectionHeading, UtilityDrawer, } from "@/components/clinical-dashboard/dashboard-shell"; import { - cleanDisplayTitle, sanitizeAnswerDisplayText, sanitizeDisplayText, } from "@/components/clinical-dashboard/display-text"; import { NaturalLanguageAnswer, ScopeAndGovernanceNotice, - SourceImage, UserQuestionBubble, } from "@/components/clinical-dashboard/answer-content"; import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; -import { - AnswerFeedbackPanel, - AnswerSafetyNotice, - AnswerSupportSummaryCard, - answerHasCentralTable, - answerSupportPriority, - ClinicalNotesChecklistPanel, - clinicalNotesCount, - clinicalNotesDisplayCountForAnswer, - compactEvidenceSummary, - type EvidenceTabName, - simpleClinicalTableProps, - evidenceMapRowsFromRenderModel, - evidenceTabCount, - evidenceTabOrder, - QuoteCards, - SafetyFindingsListContent, -} from "@/components/clinical-dashboard/evidence-panels"; +import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-panels"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; -import { emptyStates, errorCopy } from "@/lib/ui-copy"; +import { errorCopy } from "@/lib/ui-copy"; import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; import { DrawerGroupLabel, @@ -198,7 +145,7 @@ const DocumentDrawer = dynamic( ); import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results"; -import { isWeakRelevance, QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; +import { isWeakRelevance } from "@/components/clinical-dashboard/relevance"; import { answerPayloadIsUsable, isRetryableError, @@ -236,20 +183,15 @@ import { maxStoredAnswerTurns, savePersistedAnswerThread, } from "@/lib/answer-thread-storage"; -import { buildAnswerRenderModel, type AnswerRenderModel } from "@/lib/answer-render-policy"; -import { sourceTextForCompactDisplay } from "@/lib/source-text-sanitizer"; +import { buildAnswerRenderModel } from "@/lib/answer-render-policy"; import { frontendSourceGovernanceWarnings, groupSourceGovernanceWarnings, type SourceGovernanceWarning, } from "@/lib/source-governance"; -import { smartEvidenceTags } from "@/lib/evidence-tags"; import { - tagSearchText, type SmartDocumentTag, type SmartDocumentTagFacet, - type SmartDocumentTagTier, - type SmartDocumentTagQualityIssueKind, } from "@/lib/document-tags"; import type { ClinicalDocument, @@ -263,16 +205,13 @@ import type { RelatedDocument, SearchResult, SearchScopeSummary, - VisualEvidenceCard, ClinicalQueryMode, DocumentLabel, - DocumentLabelType, } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { createQuoteFollowUp, - type AnswerEvidenceMapRow, type AnswerViewMode, shouldPollForUpdates, } from "@/lib/ward-output"; @@ -487,367 +426,6 @@ async function readAnswerStream(response: Response, onProgress: (message: string function normalizeNavigationHash(hash: string) { return navigationHashes.includes(hash as (typeof navigationHashes)[number]) ? hash : "#search"; } - -function compactClinicalTableCaption(item: VisualEvidenceCard) { - const raw = item.tableTitle || item.tableLabel || item.caption || "Clinical table"; - const cleaned = sourceTextForCompactDisplay(raw) - .replace(/\btable\s+\d+\s*[:.-]?\s*/i, "") - .replace(/\b(?:page|p\.)\s*\d+\b/gi, "") - .replace(/\s{2,}/g, " ") - .trim(); - const caption = cleaned || "Clinical table"; - return caption.length <= 72 ? caption : `${caption.slice(0, 69).trim()}...`; -} - -function visualEvidenceHeader(item: VisualEvidenceCard) { - const titleSource = [item.tableLabel, item.tableTitle].filter(Boolean).join(" · "); - const titleText = sourceTextForCompactDisplay(titleSource).trim(); - const captionText = sourceTextForCompactDisplay(item.caption ?? "").trim(); - const normalizedTitle = titleText.toLowerCase(); - const normalizedCaption = captionText.toLowerCase(); - const isDuplicateCaption = - Boolean(normalizedCaption) && - (normalizedCaption.startsWith(normalizedTitle) || normalizedCaption === normalizedTitle); - return { - title: titleText || captionText || "Visual evidence", - caption: isDuplicateCaption ? null : captionText, - }; -} - -function VisualEvidenceStrip({ - evidence, - collapsed = false, - embedded = false, -}: { - evidence: VisualEvidenceCard[]; - collapsed?: boolean; - embedded?: boolean; -}) { - function looksLikeTableText(value?: string | null) { - return Boolean(value?.includes("|") && value.split("|").filter((cell) => cell.trim()).length >= 3); - } - - if (collapsed) { - return ( -
- - - -
- ); - } - - const content = ( - <> - - {evidence.length === 0 ? ( - - ) : ( -
- {evidence.map((item) => { - const tableMarkdown = item.accessibleTableMarkdown?.trim() - ? item.accessibleTableMarkdown - : looksLikeTableText(item.tableTextSnippet) - ? item.tableTextSnippet - : null; - const hasStructuredTable = Boolean(tableMarkdown || item.tableRows?.length || item.tableColumns?.length); - const tableCaption = compactClinicalTableCaption(item); - const sourceHeader = visualEvidenceHeader(item); - const displayLabels = smartEvidenceTags( - item.labels, - [[item.tableLabel, item.tableTitle].filter(Boolean).join(": "), item.caption, item.tableTextSnippet] - .filter(Boolean) - .join(" "), - ); - return ( -
-
- -
-
- {!hasStructuredTable ?

{sourceHeader.title}

: null} - {!hasStructuredTable && sourceHeader.caption ?

{sourceHeader.caption}

: null} - - {!hasStructuredTable && item.tableTextSnippet ? ( -

- {sourceTextForCompactDisplay(item.tableTextSnippet)} -

- ) : null} - {displayLabels.length ? ( -
- {displayLabels.map((label) => ( - - {label} - - ))} -
- ) : null} -
-
- - {formatCompactCitationLabel(item)} - - - {cleanDisplayTitle(item.title)}, page {item.page_number ?? "n/a"} - - {item.image_type && ( - - {item.image_type.replaceAll("_", " ")} - - )} - {!hasStructuredTable ? : null} - - - Open source - -
-
- ); - })} -
- )} - - ); - - if (embedded) return
{content}
; - - return ( -
- {content} -
- ); -} - -const evidenceTabIconMap: Record = { - Claims: CheckCircle2, - Quotes: Quote, - Tables: ListChecks, - Images: FileImage, - Gaps: AlertCircle, -}; - -function supportDotClass(supportLevel: string) { - const normalized = supportLevel.toLowerCase(); - if (normalized.includes("unsupported") || normalized.includes("none")) return "bg-[color:var(--danger)]"; - if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) { - return "bg-[color:var(--warning)]"; - } - return "bg-[color:var(--clinical-accent)]"; -} - -function supportLabel(supportLevel: string) { - const normalized = supportLevel.toLowerCase(); - if (normalized.includes("unsupported") || normalized.includes("none")) return "Unsupported"; - if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) - return "Partial"; - return "Direct"; -} - -function claimRowsForEvidencePanel(rows: AnswerEvidenceMapRow[], renderModel: AnswerRenderModel) { - if (rows.length) return rows.slice(0, 6); - return renderModel.primarySources.slice(0, 6).map((source, index) => ({ - id: source.id, - section: source.label || cleanDisplayTitle(source.title || source.file_name) || `Source ${index + 1}`, - detail: source.snippet || source.reason || "Open source passage to review the cited evidence.", - supportLevel: source.sourceStrength === "none" ? "partial" : source.sourceStrength, - citationCount: 1, - sourceStatus: - source.sourceStrength === "none" ? "Source requires review" : `${source.sourceStrength} source support`, - bestSourceLabel: source.label, - bestLinkedPassage: source.snippet || source.reason, - href: source.href, - })); -} - -function EvidenceClaimsList({ rows, renderModel }: { rows: AnswerEvidenceMapRow[]; renderModel: AnswerRenderModel }) { - const claimRows = claimRowsForEvidencePanel(rows, renderModel); - const directCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Direct").length; - const partialCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Partial").length; - - if (!claimRows.length) { - return ; - } - - return ( -
-
-
-

Claims checked

-
- - - Direct - - - - Partial - - - - Unsupported - -
-
-

- {directCount} direct · {partialCount} partial -

-
- -
- {claimRows.map((row, index) => ( - - - - - {row.section} - - {row.detail || row.bestLinkedPassage || row.bestSourceLabel} - - - - - ))} -
-
- ); -} - -function EvidenceGapsPanel({ warnings }: { warnings: string[] }) { - if (!warnings.length) { - return ( - - ); - } - - return ( -
- {warnings.map((warning, index) => ( -
- -
-

Gap {index + 1}

-

{warning}

-
-
- ))} -
- ); -} - -function MobileEvidenceTabPanel({ - tab, - renderModel, - visualEvidence, - answerEvidenceMapRows, - copiedQuotes, - onCopyQuotes, - onFollowUpQuote, - onScopeDocument, -}: { - tab: EvidenceTabName; - renderModel: AnswerRenderModel; - visualEvidence: VisualEvidenceCard[]; - answerEvidenceMapRows: AnswerEvidenceMapRow[]; - copiedQuotes: boolean; - onCopyQuotes: () => void; - onFollowUpQuote?: (quote: QuoteCard) => void; - onScopeDocument: (documentId: string) => void; -}) { - if (tab === "Claims") { - return ; - } - - if (tab === "Tables") { - const tableEvidence = visualEvidence.filter((item) => item.accessibleTableMarkdown || item.tableRows?.length); - return tableEvidence.length ? ( -
- {tableEvidence.slice(0, 4).map((item, index) => ( -
- - - -
-

- {compactClinicalTableCaption(item)} -

-

- Table {index + 1} · p.{item.page_number ?? "n/a"} -

-
- - - -
- ))} -
- ) : ( - - ); - } - - if (tab === "Images") { - return visualEvidence.length ? ( - - ) : ( - - ); - } - - if (tab === "Quotes") { - return ( - - ); - } - - return ; -} - /** * A completed Q&A exchange kept on screen after a newer answer arrives, so * Answer mode reads as a conversation thread instead of replacing each result. @@ -1355,8 +933,8 @@ function SettingsClinicalContextStrip() { Private workspace{" "} - · WA{" "} - · No PHI + ┬╖ WA{" "} + ┬╖ No PHI ); @@ -2174,7 +1752,11 @@ export function ClinicalDashboard({ process.env.NODE_ENV !== "production" && localProjectReady && hasReadyRequiredPublicSearchConfig(setupChecks); const canUsePrivateApis = localProjectReady && (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated"); - const canRunSearch = explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis; + const canUploadDocuments = + canUsePrivateApis || (publicUploadsEnabled() && canUsePublicSearchApis); + const canAttemptDeployedPublicSearch = isDeployedClinicalKb() && localProjectReady; + const canRunSearch = + explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis || canAttemptDeployedPublicSearch; const closeDashboardTransientSurfaces = useCallback( (except?: "guide" | "settings" | "accountSetup" | "mobileSidebar" | "documents" | "upload") => { if (except !== "guide") setGuideOpen(false); @@ -2355,20 +1937,25 @@ export function ClinicalDashboard({ const setupResponse = await fetch("/api/setup-status", { cache: "no-store" }).catch(() => null); if (!setupResponse) { - setApiUnavailable(true); - setSetupWarning("The local API is unavailable."); - return; - } - - if (setupResponse.ok) { + if (isDeployedClinicalKb()) { + setSetupWarning("Setup status could not be loaded. You can still try search."); + } else { + setApiUnavailable(true); + setSetupWarning("The local API is unavailable."); + return; + } + } else if (setupResponse.ok) { const payload = (await setupResponse.json()) as SetupStatusPayload; setSetupChecks(payload.checks ?? fallbackSetupChecks); nextDemoMode = Boolean(payload.demoMode); routeIndexingActive = Boolean(payload.indexingActive); routePollDelayMs = shorterPollDelay(routePollDelayMs, payload.pollAfterMs); if (nextDemoMode) setDemoMode(true); + } else if (isDeployedClinicalKb()) { + setSetupWarning("Setup status could not be loaded. You can still try search."); } else { setApiUnavailable(true); + return; } } @@ -2922,11 +2509,13 @@ export function ClinicalDashboard({ function searchNetworkFailure(label: string) { const offline = typeof navigator !== "undefined" && !navigator.onLine; - const localOrigin = typeof window !== "undefined" ? window.location.origin : "the local Clinical KB server"; + const origin = typeof window !== "undefined" ? window.location.origin : "Clinical KB"; return makeSearchError( offline ? `${label} could not run because the browser is offline.` - : `${label} could not reach Clinical KB at ${localOrigin}. The local server may still be starting or restarting; retry shortly or run npm run ensure.`, + : isDeployedClinicalKb() + ? `${label} could not reach Clinical KB at ${origin}. Check your connection and try again shortly.` + : `${label} could not reach Clinical KB at ${origin}. The local server may still be starting or restarting; retry shortly or run npm run ensure.`, undefined, true, ); @@ -3252,7 +2841,7 @@ export function ClinicalDashboard({ throw new Error("Search did not return usable results."); } - // M10: discard a stale response — a newer search owns the UI state. + // M10: discard a stale response ΓÇö a newer search owns the UI state. if (requestId === searchRequestSeqRef.current) { applySearchResult(successfulPayload, trimmedQuery); if (successfulPayload.kind === "answer") { @@ -3320,10 +2909,10 @@ export function ClinicalDashboard({ if (!autoRunSearch || !trimmedQuery || !canAutoRunMode || loading) return; if (searchMode === "answer" && !answerThreadBootstrapped) return; // Once an answer is on screen, composer edits are follow-up drafts and must - // only run on explicit submit — not on every query keystroke while run=1 + // only run on explicit submit ΓÇö not on every query keystroke while run=1 // keeps autoRunSearch enabled from the URL. if (searchMode === "answer" && answer) return; - // After reload, the URL query matches the restored latest turn — do not + // After reload, the URL query matches the restored latest turn ΓÇö do not // archive it again into a duplicate prior turn. if (searchMode === "answer" && latestAnswerQuery?.trim() === trimmedQuery) { autoRunSearchSignatureRef.current = `${searchMode}:${trimmedQuery}`; @@ -4005,21 +3594,21 @@ export function ClinicalDashboard({

); - const showAuthPanel = !clientDemoMode && !canUsePrivateApis; - const showDegradedNotice = !isOnline || apiUnavailable; + const showAuthPanel = false; + const showDegradedNotice = !isOnline || (apiUnavailable && !canRunSearch); const hasMobileBottomSearch = searchMode !== "answer"; const showDesktopHomeComposer = - !loading && !error && - ((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) || - (searchMode === "documents" && - activeModeResultKind === "documents" && - documentMatches.length === 0 && - !modeSearchSubmitted) || - (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) || - (activeModeResultKind === "differentials" && !modeSearchSubmitted) || + (activeModeResultKind === "tools" || activeModeResultKind === "favourites" || - activeModeResultKind === "tools"); + (!loading && + ((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) || + (searchMode === "documents" && + activeModeResultKind === "documents" && + documentMatches.length === 0 && + !modeSearchSubmitted) || + (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) || + (activeModeResultKind === "differentials" && !modeSearchSubmitted)))); const desktopHomeComposerSlotId = showDesktopHomeComposer ? modeHomeDesktopComposerSlotId : undefined; // Favourites and Tools are content-rich hubs: they share the centred hero but // stay top-aligned so their lists start in a stable position. @@ -4044,14 +3633,18 @@ export function ClinicalDashboard({ summary={ !isOnline ? "Your browser is offline. Existing content may remain visible, but private search and uploads need network access." - : "The local API did not respond. Check the app server and setup status before retrying." + : isDeployedClinicalKb() + ? "The app could not reach its API. Try again in a moment." + : "The local API did not respond. Check the app server and setup status before retrying." } mobileSummary={!isOnline ? "Offline" : "API unavailable"} >

{!isOnline ? "Reconnect before uploading documents, refreshing source URLs, or generating answers." - : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."} + : isDeployedClinicalKb() + ? "The app will preserve the current view. If this keeps happening, check your connection and try again shortly." + : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."}

); @@ -4079,7 +3672,7 @@ export function ClinicalDashboard({ { id: "upload", label: "Upload", - summary: uploadReadOnlyMode || !canUsePrivateApis ? "Locked" : "Ready", + summary: uploadReadOnlyMode || !canUploadDocuments ? "Locked" : "Ready", panelId: "dashboard-upload-section", icon: UploadCloud, }, @@ -4489,6 +4082,7 @@ export function ClinicalDashboard({ followUpSuggestions={answerFollowUpSuggestions} onPickFollowUpSuggestion={handlePickFollowUpSuggestion} followUpSuggestionsDisabled={loading} + scrollContainerRef={mainRef} /> ) : null @@ -4659,7 +4253,7 @@ export function ClinicalDashboard({ diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 8d875334fa..10533849e7 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1923,12 +1923,22 @@ export function DocumentViewer({ const [viewerModeInitialized] = useState(true); const generatedSummaryRef = useRef(null); const { status: authStatus, isConfigured, authorizationHeader, markSessionExpired } = useAuthSession(); + const [authLoadingTimedOut, setAuthLoadingTimedOut] = useState(false); const [serverDemoMode, setServerDemoMode] = useState(process.env.NEXT_PUBLIC_DEMO_MODE === "true"); const localNoAuthMode = isLocalNoAuthMode(); const clientDemoMode = localNoAuthMode || serverDemoMode; const canViewSourceDocuments = localProjectReady; const canUsePrivateApis = localProjectReady && (clientDemoMode || authStatus === "authenticated"); + useEffect(() => { + if (authStatus !== "loading") { + const resetId = window.setTimeout(() => setAuthLoadingTimedOut(false), 0); + return () => window.clearTimeout(resetId); + } + const timeoutId = window.setTimeout(() => setAuthLoadingTimedOut(true), 4_000); + return () => window.clearTimeout(timeoutId); + }, [authStatus]); + useEffect(() => { if (typeof window === "undefined" || !viewerModeInitialized || hasExplicitPdfViewerMode) return; @@ -2090,9 +2100,17 @@ export function DocumentViewer({ setTableFacts([]); setChunks([]); setIndexHealth(null); - setViewerError( - detailResult.reason instanceof Error ? detailResult.reason.message : "Document could not be loaded.", - ); + const message = + detailResult.reason instanceof Error ? detailResult.reason.message : "Document could not be loaded."; + if (!canUsePrivateApis && !clientDemoMode && message === "Document not found.") { + setViewerError( + isConfigured + ? "Sign in to open private source documents." + : "Supabase browser authentication is not configured for private source documents.", + ); + } else { + setViewerError(message); + } } if (signedUrlResult.status === "fulfilled") { @@ -2153,7 +2171,7 @@ export function DocumentViewer({ useEffect(() => { const query = sourceSearch.trim(); - if (!canUsePrivateApis || query.length < 2) { + if (!canViewSourceDocuments || query.length < 2) { const reset = window.setTimeout(() => { setDocumentSearchResults([]); setSearchingDocument(false); @@ -2195,7 +2213,7 @@ export function DocumentViewer({ window.clearTimeout(timeout); controller.abort(); }; - }, [authorizationHeader, canUsePrivateApis, clientDemoMode, documentId, markSessionExpired, sourceSearch]); + }, [authorizationHeader, canViewSourceDocuments, clientDemoMode, documentId, markSessionExpired, sourceSearch]); useEffect(() => { const updateOnline = () => setIsOnline(navigator.onLine); @@ -2238,8 +2256,20 @@ export function DocumentViewer({ } } - const authViewerError = null; - const effectiveLoadingDocument = loadingDocument; + const authViewerError = + !canUsePrivateApis && + !clientDemoMode && + !loadingDocument && + !document && + (authStatus !== "loading" || authLoadingTimedOut) && + (viewerError === "Sign in to open private source documents." || + viewerError === "Supabase browser authentication is not configured for private source documents.") + ? viewerError + : null; + const effectiveLoadingDocument = + !canUsePrivateApis && authStatus === "loading" && !authLoadingTimedOut && loadingDocument + ? true + : loadingDocument; const effectiveViewerError = authViewerError ?? viewerError; const viewerState = effectiveLoadingDocument ? "loading" diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index b47b1267a1..a362bc8110 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -906,12 +906,17 @@ export function ApplicationsLauncherWorkspace({ }: ApplicationsLauncherWorkspaceProps) { const [uncontrolledQuery, setUncontrolledQuery] = useState(""); const [activeFilter, setActiveFilter] = useState("all"); - const [selectedId, setSelectedId] = useState(() => initialToolId(controlledQuery)); const isDashboardTools = variant === "dashboard-tools"; const [detailOpen, setDetailOpen] = useState(!isDashboardTools && showDetailPanel === true); const copy = isDashboardTools ? dashboardToolsLauncherCopy : standaloneLauncherCopy; const query = controlledQuery ?? uncontrolledQuery; const normalizedQuery = query.trim().toLowerCase(); + const queryDerivedId = useMemo(() => initialToolId(query), [query]); + const [selection, setSelection] = useState(() => ({ + queryKey: (controlledQuery ?? "").trim().toLowerCase(), + id: initialToolId(controlledQuery), + })); + const selectedId = selection.queryKey === normalizedQuery ? selection.id : queryDerivedId; const filteredApps = useMemo(() => { return launcherApps.filter((app) => { @@ -941,7 +946,7 @@ export function ApplicationsLauncherWorkspace({ } function openTool(id: string) { - setSelectedId(id); + setSelection({ queryKey: normalizedQuery, id }); setDetailOpen(true); } diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx index b25fae0267..35062b1109 100644 --- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx +++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx @@ -188,7 +188,11 @@ export function UploadPanel({ return; } if (!canUpload) { - changeStatus("Sign in before uploading private guideline files."); + changeStatus( + demoMode + ? demoUploadReadOnlyMessage + : "Uploads are unavailable until this public workspace is configured.", + ); return; } @@ -202,7 +206,7 @@ export function UploadPanel({ setUploading(true); changeStatus( files.length === 1 - ? "Uploading private document to Supabase Storage..." + ? "Uploading document to Supabase Storage..." : `Uploading 1 of ${files.length}: ${files[0].name}`, ); @@ -226,8 +230,8 @@ export function UploadPanel({ if (failures.length === 0) { changeStatus( files.length === 1 - ? "Successfully uploaded private document to storage queue." - : `Successfully uploaded ${files.length} private documents.`, + ? "Successfully uploaded document to storage queue." + : `Successfully uploaded ${files.length} documents.`, ); if (input) input.value = ""; onUploaded(); diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index 581e10c6da..7eee483bf7 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import Link from "next/link"; import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; @@ -6,6 +6,7 @@ import { ClipboardCheck, ExternalLink, Layers, ShieldAlert } from "lucide-react" import { type AnswerFeedbackType } from "@/lib/answer-feedback"; import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; +import { useCollapseWhenContentBelow } from "@/components/clinical-dashboard/use-collapse-when-content-below"; import { NaturalLanguageAnswer, UserQuestionBubble } from "@/components/clinical-dashboard/answer-content"; import { AnswerSupportSummaryCard, @@ -61,6 +62,7 @@ export function StagedAnswerResultSurface({ followUpSuggestions, onPickFollowUpSuggestion, followUpSuggestionsDisabled = false, + scrollContainerRef, }: { answer: RagAnswer; query: string; @@ -86,6 +88,7 @@ export function StagedAnswerResultSurface({ followUpSuggestions?: string[]; onPickFollowUpSuggestion?: (suggestion: string) => void; followUpSuggestionsDisabled?: boolean; + scrollContainerRef?: RefObject; }) { const noteCount = clinicalNotesCount(answer); const showClinicalNotes = @@ -116,6 +119,8 @@ export function StagedAnswerResultSurface({ const clinicalNotesTriggerRef = useRef(null); const evidenceTriggerRef = useRef(null); const safetyTriggerRef = useRef(null); + const supportActionRowRef = useRef(null); + const belowContentSentinelRef = useRef(null); const copyQuotesTimerRef = useRef(null); useEffect(() => { return () => { @@ -180,8 +185,15 @@ export function StagedAnswerResultSurface({ weakEvidence, }); const inlineEvidenceSummary = compactEvidenceSummary(answer, sources, sourceSummary, renderModel); - const evidenceTrustLabel = inlineEvidenceSummary.split(" · ")[0] || "Review support"; + const evidenceTrustLabel = inlineEvidenceSummary.split(" ┬╖ ")[0] || "Review support"; const showInlineSupportCard = Boolean(priority || showClinicalNotes || showEvidenceDrawer); + const showSupportActionRow = showClinicalNotes || showEvidenceDrawer; + const collapseActionRow = useCollapseWhenContentBelow({ + containerRef: scrollContainerRef, + anchorRef: supportActionRowRef, + belowSentinelRef: belowContentSentinelRef, + disabled: !showSupportActionRow || !scrollContainerRef, + }); const showLayoutAside = Boolean(centralTable); return ( @@ -222,6 +234,8 @@ export function StagedAnswerResultSurface({ clinicalTriggerRef={clinicalNotesTriggerRef} evidenceTriggerRef={evidenceTriggerRef} safetyTriggerRef={safetyTriggerRef} + actionRowRef={supportActionRowRef} + collapseActionRow={collapseActionRow} safetyFindingsCount={safetyFindings.length} onOpenClinicalNotes={openClinicalNotes} onOpenEvidence={() => openEvidence(null)} @@ -236,6 +250,12 @@ export function StagedAnswerResultSurface({ disabled={followUpSuggestionsDisabled} /> ) : null} + {centralTable ? ( @@ -309,8 +329,9 @@ export function StagedAnswerResultSurface({ } - contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(88dvh,44rem)] sm:max-w-2xl" + contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(88dvh,44rem)] sm:max-w-3xl" bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" + desktopBackdropClassName="sm:bg-black/50" returnFocusRef={evidenceTriggerRef} portal > diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index 5efffa0ad0..d14f25ff1e 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -24,6 +24,7 @@ import { import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; +import { isDeployedClinicalKb } from "@/lib/deployed-app"; import { ModeHomeTemplate } from "@/components/mode-home-template"; import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; import { SafeBoldText } from "@/components/SafeBoldText"; @@ -726,7 +727,7 @@ function RecordRegistryNotice({ status, mode }: { status: RegistryRequestStatus; status === "loading" ? { Icon: Loader2, spin: true, tone: "info" as const, text: `Loading your ${noun} registry...` } : status === "unauthorized" - ? { Icon: Shield, spin: false, tone: "warning" as const, text: `Sign in to search your ${noun} registry.` } + ? { Icon: Shield, spin: false, tone: "warning" as const, text: `Your session expired. Sign in again to search your private ${noun} registry.` } : { Icon: ShieldAlert, spin: false, @@ -834,9 +835,11 @@ export function DocumentSearchResultsPanel({ } const unavailableMessage = apiUnavailable - ? "The local API is unavailable. Check the app server before searching documents." + ? isDeployedClinicalKb() + ? "Clinical KB could not be reached. Check your connection and try again shortly." + : "The local API is unavailable. Check the app server before searching documents." : authUnavailable - ? "Sign in or enable local no-auth mode before listing private indexed documents." + ? "Your session expired. Sign in again to view private indexed documents." : !realDataReady ? setupWarning || "Complete the search setup before using Documents mode." : null; diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index f15cad21f6..d411f85037 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import Link from "next/link"; import { type RefObject, useState } from "react"; @@ -151,6 +151,8 @@ export function AnswerSupportSummaryCard({ clinicalTriggerRef, evidenceTriggerRef, safetyTriggerRef, + actionRowRef, + collapseActionRow = false, safetyFindingsCount = 0, onOpenClinicalNotes, onOpenEvidence, @@ -164,6 +166,8 @@ export function AnswerSupportSummaryCard({ clinicalTriggerRef?: RefObject; evidenceTriggerRef?: RefObject; safetyTriggerRef?: RefObject; + actionRowRef?: RefObject; + collapseActionRow?: boolean; safetyFindingsCount?: number; onOpenClinicalNotes: () => void; onOpenEvidence: () => void; @@ -238,48 +242,66 @@ export function AnswerSupportSummaryCard({ {supportRowCount > 0 ? (
- {clinicalAvailable ? ( - - ) : null} - {evidenceAvailable ? ( - - ) : null} + {clinicalAvailable ? ( + + ) : null} + {evidenceAvailable ? ( + + ) : null} +
+ ) : null} @@ -505,7 +527,7 @@ function clinicalNoteHeuristicTitle(value: string) { ) { return "Escalation triggers"; } - if (/\blithium levels?\b/.test(lower) && /\b(5\s*(?:to|-|–)\s*7|dose change|stable|days?)\b/.test(lower)) { + if (/\blithium levels?\b/.test(lower) && /\b(5\s*(?:to|-|ΓÇô)\s*7|dose change|stable|days?)\b/.test(lower)) { return "Lithium level timing"; } if (/\b(lithium level|serum lithium|trough level)\b/.test(lower)) return "Lithium level check"; @@ -540,7 +562,7 @@ function clinicalNoteTitleFromItem(item: string, section: ClinicalDetailSection, } return title; } - const dashIndex = text.search(/\s[-–]\s/); + const dashIndex = text.search(/\s[-ΓÇô]\s/); if (dashIndex > 8 && dashIndex < 54) return text.slice(0, dashIndex).trim(); if (section.items.length === 1 && section.title.length <= 42) return section.title; const words = text @@ -565,7 +587,7 @@ function clinicalNoteDetailFromItem(item: string, title: string) { if (lowerText.startsWith(`${normalizedTitle}:`)) { return sentenceCaseClinicalNoteDetail(text.slice(title.length + 1).trim()); } - if (lowerText.startsWith(`${normalizedTitle} -`) || lowerText.startsWith(`${normalizedTitle} –`)) { + if (lowerText.startsWith(`${normalizedTitle} -`) || lowerText.startsWith(`${normalizedTitle} ΓÇô`)) { return sentenceCaseClinicalNoteDetail(text.slice(title.length + 2).trim()); } if (text === title) return "Review linked source context before using this note."; @@ -1018,7 +1040,7 @@ export function compactEvidenceSummary( countParts.push(`${sourceCount} source${sourceCount === 1 ? "" : "s"}`); } - return [support, ...countParts].join(" · "); + return [support, ...countParts].join(" ┬╖ "); } export type EvidenceTabName = "Claims" | "Quotes" | "Tables" | "Images" | "Gaps"; @@ -1198,7 +1220,7 @@ function RenderModelSourceList({ {cleanDisplayTitle(source.title)}

- p.{source.page_number ?? "n/a"} · {sourceStatusLabel(metadata)} · {source.sourceStrength} support + p.{source.page_number ?? "n/a"} ┬╖ {sourceStatusLabel(metadata)} ┬╖ {source.sourceStrength} support

@@ -1319,7 +1341,7 @@ export const simpleClinicalTableProps = { function compactEvidenceCell(value: string | null | undefined, max = 140) { const text = value ? value.replace(/\s+/g, " ").trim() : ""; - return text.length > max ? `${text.slice(0, max - 1).trim()}…` : text; + return text.length > max ? `${text.slice(0, max - 1).trim()}ΓǪ` : text; } export function evidenceMapRowsFromRenderModel(renderModel: AnswerRenderModel): AnswerEvidenceMapRow[] { diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index c1b2835a3d..68072f029d 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -22,7 +22,6 @@ import { favouriteItems, favouriteSets, favouriteTabs, - favouriteTypeCount, type FavouriteItem, type FavouriteSet, type FavouriteTabId, diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 05596db09c..597d4d4597 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -31,6 +31,7 @@ import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search- import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; import { medicationMatchesCommandScopes } from "@/lib/search-command-surface"; +import { isDeployedClinicalKb } from "@/lib/deployed-app"; import { cn, toneDanger, toneInfo, toneNeutral, toneSuccess, toneWarning } from "@/components/ui-primitives"; type MedicationPrescribingWorkspaceProps = { @@ -414,9 +415,13 @@ function StatusNotice({ }: Pick) { if (realDataReady && !authUnavailable && !apiUnavailable && !setupWarning) return null; const message = authUnavailable - ? "Private medication search is waiting for sign-in." + ? isDeployedClinicalKb() + ? "Sign in to search your private medication library." + : "Private medication search is waiting for sign-in." : apiUnavailable - ? "Medication search is using the local mockup while the API is unavailable." + ? isDeployedClinicalKb() + ? "Medication search is temporarily unavailable. Try again shortly." + : "Medication search is using the local mockup while the API is unavailable." : setupWarning || "Medication search setup is still warming up."; return ( diff --git a/src/components/clinical-dashboard/use-collapse-when-content-below.ts b/src/components/clinical-dashboard/use-collapse-when-content-below.ts new file mode 100644 index 0000000000..96d8301178 --- /dev/null +++ b/src/components/clinical-dashboard/use-collapse-when-content-below.ts @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState, useSyncExternalStore, type RefObject } from "react"; + +// Matches phoneSearchLayoutMediaQuery in master-search-header.tsx — the repo's +// phone/tablet seam. Collapse only ever runs below the sm breakpoint. +const phoneMediaQuery = "(max-width: 639px)"; + +// Reserved height for the fixed answer footer composer (pill + chips + safe area). +const defaultComposerInsetPx = 132; +// Small tolerance so the row hides just before it visually overlaps the composer. +const dockBandThresholdPx = 12; + +function subscribeToPhoneMedia(onChange: () => void) { + const media = window.matchMedia(phoneMediaQuery); + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); +} + +function readPhoneMedia() { + return window.matchMedia(phoneMediaQuery).matches; +} + +function readPhoneMediaServer() { + return false; +} + +interface UseCollapseWhenContentBelowOptions { + /** Scroll container (`#main-content`). */ + containerRef?: RefObject; + /** The Clinical notes / Evidence row wrapper. */ + anchorRef?: RefObject; + /** Sentinel placed after all content below the action row. */ + belowSentinelRef?: RefObject; + /** Viewport inset reserved for the fixed bottom composer. */ + composerInsetPx?: number; + /** Disables the behavior entirely (state resets to expanded). */ + disabled?: boolean; +} + +/** + * On phones, collapses the answer support action row when it sits in the + * composer dock band while unscrolled content still lives below it (e.g. + * follow-up suggestions). Expands again at the true scroll bottom or when + * the row leaves the dock band. + */ +export function useCollapseWhenContentBelow({ + containerRef, + anchorRef, + belowSentinelRef, + composerInsetPx = defaultComposerInsetPx, + disabled = false, +}: UseCollapseWhenContentBelowOptions): boolean { + const [collapsed, setCollapsed] = useState(false); + const isPhone = useSyncExternalStore(subscribeToPhoneMedia, readPhoneMedia, readPhoneMediaServer); + const active = isPhone && !disabled; + + useEffect(() => { + if (!active) return; + + const container = containerRef?.current ?? null; + const anchor = anchorRef?.current ?? null; + const sentinel = belowSentinelRef?.current ?? null; + if (!container || !anchor || !sentinel) return; + + let frame = 0; + + const evaluate = () => { + frame = 0; + const dockLine = window.innerHeight - composerInsetPx; + const anchorRect = anchor.getBoundingClientRect(); + const sentinelRect = sentinel.getBoundingClientRect(); + const inDockBand = anchorRect.bottom >= dockLine - dockBandThresholdPx; + const contentBelow = sentinelRect.top > dockLine; + setCollapsed(inDockBand && contentBelow); + }; + + const schedule = () => { + if (frame) return; + frame = window.requestAnimationFrame(evaluate); + }; + + container.addEventListener("scroll", schedule, { passive: true }); + window.addEventListener("resize", schedule, { passive: true }); + schedule(); + + return () => { + container.removeEventListener("scroll", schedule); + window.removeEventListener("resize", schedule); + if (frame) window.cancelAnimationFrame(frame); + setCollapsed(false); + }; + }, [active, containerRef, anchorRef, belowSentinelRef, composerInsetPx]); + + return active && collapsed; +} diff --git a/src/components/clinical-dashboard/visual-evidence.tsx b/src/components/clinical-dashboard/visual-evidence.tsx index 2f84e21340..5dcca9e549 100644 --- a/src/components/clinical-dashboard/visual-evidence.tsx +++ b/src/components/clinical-dashboard/visual-evidence.tsx @@ -29,7 +29,6 @@ import { evidenceTabOrder, QuoteCards, simpleClinicalTableProps, - VerificationWorkspace, } from "@/components/clinical-dashboard/evidence-panels"; import { QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; import { diff --git a/src/components/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx index eb3e040de3..03a6495c14 100644 --- a/src/components/forms/forms-home-page.tsx +++ b/src/components/forms/forms-home-page.tsx @@ -90,10 +90,10 @@ export function FormsHomePage() { ) : registry.status === "unauthorized" ? ( ) : registry.status === "error" ? ( } - title="Sign in required" - body={`Sign in to view this ${copy.noun}. Registry records are private to your workspace.`} - action={{ href: "/", label: "Go to sign in" }} + title="Session expired" + body={`Your session expired. Sign in again to view your private ${copy.noun}. Public ${copy.noun} records remain available from search.`} + action={{ href: "/", label: "Open account setup" }} /> ); } diff --git a/src/components/services/services-home-page.tsx b/src/components/services/services-home-page.tsx index 132432f578..6624fc2e62 100644 --- a/src/components/services/services-home-page.tsx +++ b/src/components/services/services-home-page.tsx @@ -92,10 +92,10 @@ export function ServicesHomePage() { ) : registry.status === "unauthorized" ? ( ) : registry.status === "error" ? ( ({ urlQuery, value: initialQuery })); const query = localQuery.urlQuery === urlQuery ? localQuery.value : initialQuery; const registry = useRegistryRecords("service"); - const searchableRecords = - registry.status === "ready" ? registry.records : registry.status === "loading" ? [] : serviceRecords; + const searchableRecords = useMemo( + () => (registry.status === "ready" ? registry.records : registry.status === "loading" ? [] : serviceRecords), + [registry.records, registry.status], + ); const matches = useMemo(() => { const ranked = rankServiceRecords(searchableRecords, query); return ranked.length ? ranked.map((match) => match.service) : query.trim() ? [] : searchableRecords; diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index 2a19a21bad..593cd9cfbf 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -1,10 +1,23 @@ import { NextResponse } from "next/server"; +import { isLocalNoAuthMode } from "@/lib/env"; import { PublicApiError } from "@/lib/http"; import type { RateLimitSubject } from "@/lib/public-api-access"; import type { createAdminClient } from "@/lib/supabase/admin"; +/** Prefer durable RPC rate limits; fall back to per-instance memory when the DB function is unavailable. */ +export function allowRateLimitInMemoryFallbackOnUnavailable() { + return isLocalNoAuthMode() || process.env.NODE_ENV === "production"; +} + export type ApiRateLimitBucket = - "answer" | "search" | "document_summarize" | "document_reindex" | "bulk_reindex" | "registry"; + | "answer" + | "search" + | "document_read" + | "document_upload" + | "document_summarize" + | "document_reindex" + | "bulk_reindex" + | "registry"; export type ApiRateLimitResult = { limited: boolean; @@ -17,6 +30,8 @@ export type ApiRateLimitResult = { const apiRateLimitDefaults = { answer: { limit: 30, windowSeconds: 60 }, search: { limit: 240, windowSeconds: 60 }, + document_read: { limit: 180, windowSeconds: 60 }, + document_upload: { limit: 12, windowSeconds: 60 }, document_summarize: { limit: 12, windowSeconds: 60 }, document_reindex: { limit: 6, windowSeconds: 60 }, bulk_reindex: { limit: 2, windowSeconds: 60 }, @@ -26,6 +41,8 @@ const apiRateLimitDefaults = { const anonymousApiRateLimitDefaults: Partial> = { answer: { limit: 6, windowSeconds: 60 }, search: { limit: 60, windowSeconds: 60 }, + document_read: { limit: 45, windowSeconds: 60 }, + document_upload: { limit: 3, windowSeconds: 60 }, }; type SupabaseAdmin = ReturnType; diff --git a/src/lib/deployed-app.ts b/src/lib/deployed-app.ts new file mode 100644 index 0000000000..82bb8f3a71 --- /dev/null +++ b/src/lib/deployed-app.ts @@ -0,0 +1,3 @@ +export function isDeployedClinicalKb() { + return process.env.NODE_ENV === "production"; +} diff --git a/src/lib/env.ts b/src/lib/env.ts index ac5d8dbf57..f6acba54fe 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -11,6 +11,8 @@ const envSchema = z.object({ LOCAL_NO_AUTH: z.enum(["true", "false"]).optional().default("false"), LOCAL_NO_AUTH_OWNER_EMAIL: z.string().optional(), LOCAL_NO_AUTH_OWNER_ID: z.string().optional(), + PUBLIC_WORKSPACE_OWNER_ID: z.string().uuid().optional(), + NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED: z.enum(["true", "false"]).optional(), OPENAI_API_KEY: z.string().optional(), OPENAI_EMBEDDING_MODEL: z.string().default("text-embedding-3-small"), // Must match the vector(N) dimension in supabase/schema.sql. Changing the embedding @@ -192,3 +194,11 @@ export function isLocalNoAuthMode() { return process.env.NODE_ENV !== "production" && (publicNoAuth || serverNoAuth); } + +export function publicWorkspaceOwnerId() { + return env.PUBLIC_WORKSPACE_OWNER_ID?.trim() || null; +} + +export function publicUploadsEnabled() { + return env.NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED === "true"; +} diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts index 8ae3473e16..04a9da0bac 100644 --- a/src/lib/public-api-access.ts +++ b/src/lib/public-api-access.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import type { createAdminClient } from "@/lib/supabase/admin"; +import { consumeSubjectApiRateLimit, allowRateLimitInMemoryFallbackOnUnavailable, type ApiRateLimitResult } from "@/lib/api-rate-limit"; import { getOptionalAuthenticatedUser } from "@/lib/supabase/auth"; type AdminClient = ReturnType; @@ -25,22 +26,35 @@ export function anonymousApiSubjectKey(request: Request) { return `anon:${createHash("sha256").update(source).digest("hex").slice(0, 32)}`; } -export function hasPublicApiAuthSignal(request: Request) { - const authorization = request.headers.get("authorization") ?? ""; - if (/^Bearer\s+\S+/i.test(authorization)) return true; - +export function hasSessionCookieSignal(request: Request) { const cookieHeader = request.headers.get("cookie") ?? ""; return cookieHeader.includes("sb-"); } +export function hasBearerAuthAttempt(request: Request) { + const authorization = request.headers.get("authorization") ?? ""; + return /^Bearer\s+\S+/i.test(authorization); +} + +/** True when the request may carry a durable Supabase session (cookie), not a bare bearer attempt. */ +export function hasPublicApiAuthSignal(request: Request) { + return hasSessionCookieSignal(request); +} + +/** Anonymous callers with no cookie or bearer skip auth resolution and rate limits on curated public catalogs. */ +export function shouldResolvePublicCatalogAccess(request: Request) { + return hasSessionCookieSignal(request) || hasBearerAuthAttempt(request); +} + type OwnerScopedQuery = { eq(column: string, value: unknown): T; is(column: string, value: null): T; + or(filters: string): T; }; -/** Scope document reads to the authenticated owner or public (owner_id IS NULL) rows. */ +/** Scope reads to public rows (owner_id IS NULL) and, when signed in, the caller's owned rows. */ export function withOwnerReadScope>(query: T, ownerId: string | undefined): T { - if (ownerId) return query.eq("owner_id", ownerId); + if (ownerId) return query.or(`owner_id.eq.${ownerId},owner_id.is.null`); return query.is("owner_id", null); } @@ -60,3 +74,17 @@ export async function publicAccessContext(request: Request, supabase: AdminClien rateLimitSubject: { kind: "anonymous", subjectKey: anonymousApiSubjectKey(request) } satisfies RateLimitSubject, }; } + +export async function enforceDocumentReadRateLimit( + request: Request, + supabase: AdminClient, +): Promise<{ access: Awaited>; rateLimit: ApiRateLimitResult }> { + const access = await publicAccessContext(request, supabase); + const rateLimit = await consumeSubjectApiRateLimit({ + supabase, + subject: access.rateLimitSubject, + bucket: "document_read", + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), + }); + return { access, rateLimit }; +} diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 6adea390f2..7192677a3b 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1441,7 +1441,14 @@ function stableHash(value: string) { export function retrievalPlanCacheQuery( args: Pick< SearchChunksArgs, - "query" | "documentId" | "documentIds" | "ownerId" | "queryMode" | "topK" | "minSimilarity" | "forceEmbedding" + | "query" + | "documentId" + | "documentIds" + | "ownerId" + | "queryMode" + | "topK" + | "minSimilarity" + | "forceEmbedding" >, queryClass?: RagQueryClass, queryVariants: string[] = [], @@ -1456,6 +1463,7 @@ export function retrievalPlanCacheQuery( `mode:${modeKey(args)}`, `topK:${args.topK ?? 8}`, `min:${args.minSimilarity ?? 0.15}`, + `forceEmbedding:${args.forceEmbedding ? "1" : "0"}`, `rag:${ragDeepMemoryVersion}`, `force:${args.forceEmbedding ? 1 : 0}`, ].join("|"); @@ -1554,7 +1562,7 @@ type SharedCacheMissReason = function sharedCacheSelector( supabase: ReturnType, kind: SharedCacheKind, - args: Pick, + args: Pick, indexingVersion: string, normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)), ) { @@ -1719,7 +1727,10 @@ async function getSharedCachedSearch( } async function getSharedCachedAnswer( - args: Pick, + args: Pick< + SearchChunksArgs, + "query" | "documentId" | "documentIds" | "ownerId" | "skipCache" | "queryMode" | "forceEmbedding" + >, startedAt: number, ) { if (args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return null; @@ -1752,7 +1763,7 @@ async function getSharedCachedAnswer( async function replaceSharedCacheRow( kind: SharedCacheKind, - args: Pick, + args: Pick, payload: unknown, ttlMs: number, normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)), @@ -1804,7 +1815,10 @@ function setSharedCachedSearch( } function setSharedCachedAnswer( - args: Pick, + args: Pick< + SearchChunksArgs, + "query" | "documentId" | "documentIds" | "ownerId" | "skipCache" | "queryMode" | "forceEmbedding" + >, answer: RagAnswer, ) { if (args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return; @@ -5386,6 +5400,9 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { // When the provider is source-only (offline mode, or auto mode without a usable key) we must // never call OpenAI for embeddings; retrieval falls back to the lexical text-fast-path only. const sourceOnlyRetrieval = isSourceOnlyMode(); + if (args.forceEmbedding && sourceOnlyRetrieval) { + throw new Error("forceEmbedding requires embedding-capable retrieval; source-only mode cannot exercise vectors."); + } // A3: shared across every withMemoryBoostedCandidates call in this request so the same // owner/query memory cards are fetched at most once per (query, embedding-present, count). const memoryCardCache: MemoryCardCache = new Map(); @@ -5463,7 +5480,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.shared_cache_miss_reason = sharedCached.reason; } - if (shouldApplyUnsupportedSearchShortCircuit(retrievalQuery, queryAnalysis, ragAliasExpansions)) { + if (!args.forceEmbedding && shouldApplyUnsupportedSearchShortCircuit(retrievalQuery, queryAnalysis, ragAliasExpansions)) { // Item 10 follow-up (RC6): a typo can make an on-topic query ("schizophrenai management") look // unsupported and short-circuit before any layer runs. Before giving up, trigram-correct the // query against the known clinical-term vocabulary; if it changes, re-run the whole retrieval @@ -5722,7 +5739,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry, }); const coverageGate = evaluateEvidenceCoverageGate(args.query, coverageGateResults, queryClassification.queryClass); - applyCoverageGateTelemetry(telemetry, coverageGate, coverageGate.accepted); + applyCoverageGateTelemetry(telemetry, coverageGate, !args.forceEmbedding && coverageGate.accepted); if (!args.forceEmbedding && coverageGate.accepted) { telemetry.retrieval_strategy = coverageGate.strategy; recordSearchScoreTelemetry(telemetry, coverageGateResults); @@ -5752,7 +5769,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { } catch (error) { // In auto mode a failed embedding call (e.g. quota exhausted) degrades to the lexical // results already gathered rather than failing the whole search. "openai" mode rethrows. - if (!allowsAutoDegrade()) throw error; + if (args.forceEmbedding || !allowsAutoDegrade()) throw error; telemetry.embedding_skipped = true; telemetry.embedding_skip_reason = sourceOnlyReason(error); telemetry.vector_skipped_reason = classifyProviderFailure(error); @@ -5854,10 +5871,14 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { latencyMs: hybridResult.latencyMs, topScore: layerTopScore((hybridData ?? []) as SearchResult[]), }); + const vectorCandidates = mergeSearchResults( + mergeSearchResults((hybridData ?? []) as SearchResult[], embeddingFieldCandidates), + indexUnitCandidates, + ); if (!hybridError) { const rerankStartedAt = Date.now(); - const merged = mergeSearchResults((hybridData ?? []) as SearchResult[], textFastResults); + const merged = args.forceEmbedding ? vectorCandidates : mergeSearchResults(vectorCandidates, textFastResults); const mergedWithMetadata = await attachDocumentRankingMetadata(supabase, merged, args.ownerId); const memoryBoost = await withMemoryBoostedCandidates({ supabase, @@ -5918,7 +5939,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { return (data ?? []) as SearchResult[]; }), ).catch((error) => { - if (textFastResults.length > 0) return [] as SearchResult[][]; + if (!args.forceEmbedding && textFastResults.length > 0) return [] as SearchResult[][]; throw error; }); const fallbackLatencyMs = Date.now() - fallbackRpcStartedAt; @@ -5930,9 +5951,13 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { }); const rerankStartedAt = Date.now(); + const fallbackVectorCandidates = mergeSearchResults( + mergeSearchResults(resultSets.flat(), embeddingFieldCandidates), + indexUnitCandidates, + ); const mergedWithMetadata = await attachDocumentRankingMetadata( supabase, - mergeSearchResults(resultSets.flat(), textFastResults), + args.forceEmbedding ? fallbackVectorCandidates : mergeSearchResults(fallbackVectorCandidates, textFastResults), args.ownerId, ); const memoryBoost = await withMemoryBoostedCandidates({ diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts index 044384b07c..3ecf6122c9 100644 --- a/src/lib/supabase/auth.ts +++ b/src/lib/supabase/auth.ts @@ -31,12 +31,14 @@ function readCookies(cookieHeader: string | null): Map { return cookies; } -function extractSessionAccessToken(request: Request): string | null { +function extractBearerAccessToken(request: Request): string | null { const authorization = request.headers.get("authorization") ?? ""; const match = authorization.match(/^Bearer\s+(.+)$/i); const headerToken = match?.[1]?.trim(); - if (headerToken) return headerToken; + return headerToken || null; +} +function extractCookieSessionAccessToken(request: Request): string | null { const cookies = readCookies(request.headers.get("cookie")); const legacyAccessToken = cookies.get("sb-access-token")?.trim(); if (legacyAccessToken) return legacyAccessToken; @@ -57,6 +59,19 @@ function extractSessionAccessToken(request: Request): string | null { return null; } +function extractSessionAccessToken(request: Request): string | null { + return extractBearerAccessToken(request) ?? extractCookieSessionAccessToken(request); +} + +async function getUserFromAccessToken( + supabase: AdminClient, + token: string, +): Promise { + const { data, error } = await supabase.auth.getUser(token); + if (error || !data.user?.id) return null; + return { id: data.user.id }; +} + export class AuthenticationError extends Error { constructor(message = "Authentication required.") { super(message); @@ -72,7 +87,7 @@ export function unauthorizedResponse(error?: AuthenticationError) { /** * Resolve the user from the `@supabase/ssr` cookie session. The * `sb--auth-token` cookie it writes is base64-encoded (and chunked when - * large), which `extractSessionAccessToken`'s plain-JSON parser cannot read, so + * large), which `extractCookieSessionAccessToken`'s plain-JSON parser cannot read, so * this uses the ssr server client to decode + validate it. Returns null when * the public env is absent or no `sb-` cookie is present. */ @@ -102,22 +117,28 @@ async function getUserFromRequestCookies(request: Request): Promise { - // 1. Bearer token / legacy cookie (programmatic callers + current clients). - const token = extractSessionAccessToken(request); - if (token) { - const { data, error } = await supabase.auth.getUser(token); - if (!error && data.user?.id) { - return { id: data.user.id }; - } +async function resolveOptionalAuthenticatedUser( + request: Request, + supabase: AdminClient, +): Promise { + const bearerToken = extractBearerAccessToken(request); + if (bearerToken) { + const bearerUser = await getUserFromAccessToken(supabase, bearerToken); + if (bearerUser) return bearerUser; } - // 2. @supabase/ssr cookie session (persistent cookie logins). - const cookieUser = await getUserFromRequestCookies(request); - if (cookieUser) { - return cookieUser; + const cookieToken = extractCookieSessionAccessToken(request); + if (cookieToken && cookieToken !== bearerToken) { + const cookieTokenUser = await getUserFromAccessToken(supabase, cookieToken); + if (cookieTokenUser) return cookieTokenUser; } + return getUserFromRequestCookies(request); +} + +export async function requireAuthenticatedUser(request: Request, supabase: AdminClient): Promise { + const user = await resolveOptionalAuthenticatedUser(request, supabase); + if (user) return user; throw new AuthenticationError(); } @@ -125,14 +146,8 @@ export async function getOptionalAuthenticatedUser( request: Request, supabase: AdminClient, ): Promise { - const token = extractSessionAccessToken(request); - if (token) { - const { data, error } = await supabase.auth.getUser(token); - if (!error && data.user?.id) { - return { id: data.user.id }; - } - // Invalid or expired Bearer token: fall through to cookie session, then anonymous. - } - - return getUserFromRequestCookies(request); + return resolveOptionalAuthenticatedUser(request, supabase); } + +// Retained for callers that only need a single token string. +export { extractSessionAccessToken }; diff --git a/src/lib/use-registry-records.ts b/src/lib/use-registry-records.ts index 0cf8a09da9..79599d5afd 100644 --- a/src/lib/use-registry-records.ts +++ b/src/lib/use-registry-records.ts @@ -82,8 +82,12 @@ export function useRegistryRecords( // effect retries with a real header; never expire the session from an // auth-loading 401. Demo/local API responses can still resolve fast. if (authStatus === "loading") return; - if (authStatus === "authenticated") markSessionExpired(); - setState(recordsState("unauthorized", kind)); + if (authStatus === "authenticated") { + markSessionExpired(); + setState(recordsState("unauthorized", kind)); + return; + } + setState(recordsState("error", kind)); return; } if (!response.ok) { @@ -140,8 +144,12 @@ export function useRegistryRecord(kind: RegistryRecordKind, slug: string): Regis if (!active) return; if (response.status === 401) { if (authStatus === "loading") return; - if (authStatus === "authenticated") markSessionExpired(); - setState({ status: "unauthorized", record: null, linkedDocuments: [], demoMode: false, governance: null }); + if (authStatus === "authenticated") { + markSessionExpired(); + setState({ status: "unauthorized", record: null, linkedDocuments: [], demoMode: false, governance: null }); + return; + } + setState({ status: "error", record: null, linkedDocuments: [], demoMode: false, governance: null }); return; } if (response.status === 404) { diff --git a/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql b/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql new file mode 100644 index 0000000000..473fdb90c2 --- /dev/null +++ b/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql @@ -0,0 +1,72 @@ +-- Tighten search_document_chunks owner scoping so null p_owner_id only matches public +-- documents (owner_id IS NULL) and authenticated callers can search both owned and public +-- documents without matching other owners' private rows. + +create or replace function public.search_document_chunks( + p_document_id uuid, + p_query text, + match_count integer default 20, + p_owner_id uuid default null +) +returns table ( + id uuid, + page_number integer, + chunk_index integer, + section_heading text, + content text, + image_ids uuid[], + text_rank real, + trigram_score real +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with normalized as ( + select + websearch_to_tsquery('english', coalesce(p_query, '')) as query_tsv, + lower(trim(coalesce(p_query, ''))) as query_text + ), + tokens as ( + select distinct token + from normalized, + lateral regexp_split_to_table(normalized.query_text, '\s+') as token + where length(token) >= 3 + ) + select + c.id, + c.page_number, + c.chunk_index, + c.section_heading, + c.content, + c.image_ids, + ts_rank_cd(c.search_tsv, normalized.query_tsv)::real as text_rank, + similarity(lower(coalesce(c.section_heading, '') || ' ' || c.content), normalized.query_text)::real as trigram_score + from public.document_chunks c + join public.documents d on d.id = c.document_id + cross join normalized + where c.document_id = p_document_id + and d.status = 'indexed' + and ( + (p_owner_id is null and d.owner_id is null) + or (p_owner_id is not null and (d.owner_id is null or d.owner_id = p_owner_id)) + ) + and ( + c.search_tsv @@ normalized.query_tsv + or lower(coalesce(c.section_heading, '') || ' ' || c.content) % normalized.query_text + or lower(coalesce(c.section_heading, '') || ' ' || c.content) like '%' || normalized.query_text || '%' + or exists ( + select 1 + from tokens t + where lower(coalesce(c.section_heading, '') || ' ' || c.content) like '%' || t.token || '%' + or lower(coalesce(c.section_heading, '') || ' ' || c.content) % t.token + ) + ) + order by + ts_rank_cd(c.search_tsv, normalized.query_tsv) desc, + similarity(lower(coalesce(c.section_heading, '') || ' ' || c.content), normalized.query_text) desc, + c.chunk_index asc + limit least(greatest(match_count, 1), 80); +$$; + +grant execute on function public.search_document_chunks(uuid, text, integer, uuid) to service_role; diff --git a/tests/api-rate-limit-fallback.test.ts b/tests/api-rate-limit-fallback.test.ts new file mode 100644 index 0000000000..c25bb36c5b --- /dev/null +++ b/tests/api-rate-limit-fallback.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("allowRateLimitInMemoryFallbackOnUnavailable", () => { + it("enables fallback for production deployments", async () => { + vi.stubEnv("NODE_ENV", "production"); + const { allowRateLimitInMemoryFallbackOnUnavailable } = await import("../src/lib/api-rate-limit"); + expect(allowRateLimitInMemoryFallbackOnUnavailable()).toBe(true); + }); + + it("enables fallback for local no-auth development", async () => { + vi.stubEnv("NODE_ENV", "development"); + vi.doMock("@/lib/env", () => ({ + isLocalNoAuthMode: () => true, + })); + const { allowRateLimitInMemoryFallbackOnUnavailable } = await import("../src/lib/api-rate-limit"); + expect(allowRateLimitInMemoryFallbackOnUnavailable()).toBe(true); + }); +}); diff --git a/tests/api-validation-contract.test.ts b/tests/api-validation-contract.test.ts index 5d68487d8b..a23c87c0e1 100644 --- a/tests/api-validation-contract.test.ts +++ b/tests/api-validation-contract.test.ts @@ -147,7 +147,23 @@ function createSupabaseMock(resolve: QueryResolver = () => ok([])) { calls.push(call); return new QueryBuilder(call, resolve); }), - rpc: vi.fn(async () => ok([])), + rpc: vi.fn(async (name: string) => { + if (name === "consume_api_subject_rate_limit" || name === "consume_api_rate_limit") { + return { + data: [ + { + limited: false, + limit_value: 100, + remaining: 99, + retry_after_seconds: 60, + reset_at: new Date(Date.now() + 60_000).toISOString(), + }, + ], + error: null, + }; + } + return ok([]); + }), storage: { from: storageFrom }, storageMocks: { upload, remove, createSignedUrl, storageFrom }, }; diff --git a/tests/eval-quality.test.ts b/tests/eval-quality.test.ts index d4888a9353..fa4c7e48c6 100644 --- a/tests/eval-quality.test.ts +++ b/tests/eval-quality.test.ts @@ -9,12 +9,13 @@ import { sourceWarningsForRagQualityAnswer, type RagQualityResult, } from "../scripts/eval-quality"; -import type { GoldenRetrievalResult } from "../scripts/eval-retrieval"; +import { evaluateGoldenRetrievalCase, type GoldenRetrievalResult } from "../scripts/eval-retrieval"; function retrievalResult(overrides: Partial = {}): GoldenRetrievalResult { const base: GoldenRetrievalResult = { id: "retrieval-1", query: "What ANC threshold should withhold clozapine?", + forceEmbedding: false, expectedQueryClass: "table_threshold", actualQueryClass: "table_threshold", expectedDocumentSubstrings: ["clozapine.pdf"], @@ -166,6 +167,8 @@ describe("eval quality reporting", () => { structured_threshold_text_match: 1, }); expect(report.retrieval.summary.second_stage_rerank_rate).toBe(0.5); + expect(report.retrieval.summary.force_embedding_case_count).toBe(0); + expect(report.retrieval.summary.force_embedding_failure_count).toBe(0); expect(report.rag.summary.unsupported_correct_rate).toBe(0); expect(report.rag.summary.numeric_grounding_failure_rate).toBeCloseTo(0.3333, 4); expect(report.rag.summary.source_governance_danger_failure_rate).toBeCloseTo(0.3333, 4); @@ -180,6 +183,53 @@ describe("eval quality reporting", () => { ); }); + it("fails forced-embedding retrieval cases that return from cache, coverage, or lexical paths", () => { + const result = evaluateGoldenRetrievalCase({ + testCase: { + id: "vector-regression", + query: "How is panic disorder managed?", + expectedQueryClass: "broad_summary", + expectedDocumentSubstrings: [], + expectedContentTerms: [], + topK: 8, + expectTableEvidence: false, + forceEmbedding: true, + }, + results: [], + telemetry: { + query_class: "broad_summary", + retrieval_strategy: "text_fast_path", + embedding_skipped: true, + embedding_skip_reason: "strong_document_text_score", + text_fast_path_reason: "strong_document_text_score", + text_candidate_budget: 32, + text_candidate_count: 5, + vector_candidate_count: 0, + retrieval_layer_counts: { text_candidates: 5 }, + coverage_gate_decision: "accepted", + coverage_gate_reason: "document_title_evidence_gate", + second_stage_rerank_used: false, + }, + latencyMs: 10, + }); + + expect(result.forceEmbedding).toBe(true); + expect(result.failures).toEqual( + expect.arrayContaining([ + "forceEmbedding expected embedding to run", + "forceEmbedding returned lexical strategy text_fast_path", + "forceEmbedding returned coverage gate", + "forceEmbedding found no vector-layer candidates", + ]), + ); + const report = buildEvalQualityReport({ retrievalResults: [result], ragResults: [] }); + expect(report.retrieval.summary.force_embedding_case_count).toBe(1); + expect(report.retrieval.summary.force_embedding_failure_count).toBe(1); + expect(report.blocking_threshold_failures).toEqual( + expect.arrayContaining([expect.stringContaining("retrieval force_embedding_failure_count 1 above 0")]), + ); + }); + it("derives source governance warnings for direct RAG answers without precomputed warnings", () => { const warnings = sourceWarningsForRagQualityAnswer({ sourceGovernanceWarnings: undefined, diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 9d91205f7c..92438c3699 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -276,7 +276,14 @@ function createSupabaseMock(resolve: QueryResolver = defaultQueryResolver) { function mockRuntime( client: ReturnType, ragMock?: Record, - options: { localNoAuth?: boolean; localOwnerEmail?: string; providerMode?: string; openAiKey?: string } = {}, + options: { + localNoAuth?: boolean; + localOwnerEmail?: string; + providerMode?: string; + openAiKey?: string; + publicUploadsEnabled?: boolean; + publicWorkspaceOwnerId?: string; + } = {}, ) { vi.resetModules(); vi.doUnmock("@/lib/rag"); @@ -298,11 +305,15 @@ function mockRuntime( OPENAI_API_KEY: options.openAiKey ?? "sk-test", RAG_PROVIDER_MODE: options.providerMode ?? "auto", LOCAL_NO_AUTH_OWNER_EMAIL: options.localOwnerEmail, + PUBLIC_WORKSPACE_OWNER_ID: options.publicWorkspaceOwnerId, + NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED: options.publicUploadsEnabled ? "true" : undefined, WORKER_STALE_AFTER_MINUTES: 10, WORKER_MAX_ATTEMPTS: 3, }, isDemoMode: () => false, isLocalNoAuthMode: () => Boolean(options.localNoAuth), + publicWorkspaceOwnerId: () => options.publicWorkspaceOwnerId ?? null, + publicUploadsEnabled: () => Boolean(options.publicUploadsEnabled), requireOpenAIEnv: () => undefined, requireServerEnv: () => undefined, })); @@ -322,6 +333,15 @@ function localPortRequest(port: number, path: string, init?: RequestInit) { return new Request(`http://localhost:${port}${path}`, init); } +function matchesOwnerReadScope(call: QueryCall, ownerId?: string | null) { + if (ownerId === undefined || ownerId === null) { + return call.filters.some((filter) => filter.column === "owner_id" && filter.value === null); + } + return call.orFilters.some( + (filter) => filter.includes(`owner_id.eq.${ownerId}`) && filter.includes("owner_id.is.null"), + ); +} + function authenticatedRequest(path: string, init?: RequestInit) { return request(path, { ...init, @@ -386,7 +406,7 @@ describe("private document API access", () => { const body = await payload(response); expect(response.status).toBe(200); - expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null }))); + expect(body.documents).toEqual(documents); expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: null }); expect(client.auth.getUser).not.toHaveBeenCalled(); }); @@ -416,7 +436,8 @@ describe("private document API access", () => { }), ); - expect(response.status).toBe(401); + expect(response.status).toBe(503); + expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); @@ -438,7 +459,8 @@ describe("private document API access", () => { }), ); - expect(response.status).toBe(401); + expect(response.status).toBe(503); + expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); @@ -459,7 +481,7 @@ describe("private document API access", () => { expect(client.calls[0].filters).not.toContainEqual({ column: "owner_id", value: userId }); }); - it("filters authenticated document listing by owner", async () => { + it("filters authenticated document listing by owner and public rows", async () => { const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }]; const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); mockRuntime(client); @@ -471,9 +493,8 @@ describe("private document API access", () => { expect(response.status).toBe(200); expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null }))); expect(body.pagination).toMatchObject({ limit: 100, offset: 0, nextOffset: 1, hasMore: false }); - expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId }); - expect(client.calls[0].selected).toContain("id,owner_id,title"); - expect(client.calls[0].selected).not.toBe("*"); + expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`); + expect(client.calls[0].selected).toContain("storage_path"); expect(client.calls[0].range).toEqual({ from: 0, to: 99 }); }); @@ -489,7 +510,7 @@ describe("private document API access", () => { expect(response.status).toBe(200); expect(client.auth.getUser).toHaveBeenCalledWith(token); expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null }))); - expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId }); + expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`); }); it("accepts Supabase auth token cookies for private document access", async () => { @@ -504,7 +525,124 @@ describe("private document API access", () => { expect(response.status).toBe(200); expect(client.auth.getUser).toHaveBeenCalledWith(token); expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null }))); - expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId }); + expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`); + }); + + it("allows authenticated users to read public document detail", async () => { + const client = createSupabaseMock((call) => { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { + return ok({ + id: documentId, + owner_id: null, + title: "Public guideline", + file_name: "guideline.pdf", + file_type: "application/pdf", + page_count: 2, + chunk_count: 1, + metadata: { index_generation_id: "generation-a" }, + }); + } + if (call.table === "document_pages") return ok([]); + if (call.table === "document_images") return ok([]); + if (call.table === "document_chunks") return ok([]); + if (call.table === "document_table_facts") return ok([]); + 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); + + expect(response.status).toBe(200); + expect(body.document).toMatchObject({ id: documentId, title: "Public guideline", owner_id: null }); + expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`); + }); + + it("allows authenticated users to open public document signed URLs", async () => { + const client = createSupabaseMock((call) => { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { + return ok({ storage_path: "public/documents/guideline.pdf", file_type: "application/pdf" }); + } + return ok(null); + }); + mockRuntime(client); + const { GET } = await import("../src/app/api/documents/[id]/signed-url/route"); + + const response = await GET(authenticatedRequest(`/api/documents/${documentId}/signed-url`), { + params: Promise.resolve({ id: documentId }), + }); + + expect(response.status).toBe(200); + expect((await payload(response)).url).toContain("public/documents/guideline.pdf"); + }); + + it("recovers valid cookie auth when a stale bearer header is also present", async () => { + const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }]; + const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); + mockRuntime(client); + const { GET } = await import("../src/app/api/documents/route"); + + const response = await GET( + request("/api/documents", { + headers: { + authorization: "Bearer expired-token", + cookie: `sb-access-token=${token}`, + }, + }), + ); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null }))); + expect(client.auth.getUser).toHaveBeenCalledWith(token); + }); + + it("omits internal document list fields for anonymous callers", async () => { + const documents = [{ id: documentId, owner_id: null, title: "Public guideline", status: "indexed" }]; + const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); + mockRuntime(client); + const { GET } = await import("../src/app/api/documents/route"); + + const response = await GET(request("/api/documents?includeMeta=true")); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(client.calls[0].selected).not.toContain("storage_path"); + expect(client.calls[0].selected).not.toContain("content_hash"); + expect(body.documents).toEqual(documents); + }); + + it("rate limits anonymous document read bursts", async () => { + const client = createSupabaseMock((call) => (call.table === "documents" ? ok([]) : ok([]))); + mockRuntime(client); + client.rpc.mockImplementation(async (name: string, args?: Record) => { + if (name === "consume_api_subject_rate_limit" && args?.p_bucket === "document_read") { + return { + data: [rateLimitRow({ limited: true, remaining: 0, retry_after_seconds: 30 })], + error: null, + }; + } + if (name === "consume_api_rate_limit") { + return { data: [rateLimitRow()], error: null }; + } + return ok([]); + }); + const { GET } = await import("../src/app/api/documents/route"); + + const response = await GET(request("/api/documents")); + + expect(response.status).toBe(429); + expect(await payload(response)).toMatchObject({ + error: "Document requests are rate limited. Try again shortly.", + retryAfterSeconds: 30, + }); + expect(client.rpc).toHaveBeenCalledWith( + "consume_api_subject_rate_limit", + expect.objectContaining({ p_bucket: "document_read" }), + ); }); it("does not return raw internal database errors", async () => { @@ -536,7 +674,7 @@ describe("private document API access", () => { 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)) { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { return ok({ storage_path: `${userId}/documents/${documentId}/source.pdf`, file_type: "application/pdf" }); } return ok(null); @@ -624,7 +762,7 @@ describe("private document API access", () => { metadata: { index_generation_id: "generation-a" }, }); } - if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { return ok({ id: documentId, metadata: { index_generation_id: "generation-a" } }); } return ok(null); @@ -653,7 +791,7 @@ describe("private document API access", () => { metadata: { index_generation_id: "generation-a" }, }); } - if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { return ok({ id: documentId, metadata: {} }); } return ok(null); @@ -682,7 +820,7 @@ describe("private document API access", () => { metadata: { index_generation_id: "generation-new" }, }); } - if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { return ok({ id: documentId, metadata: { index_generation_id: "generation-old" } }); } return ok(null); @@ -722,6 +860,58 @@ describe("private document API access", () => { expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled(); }); + it("rejects anonymous upload with setup guidance when public uploads are not configured", async () => { + const client = createSupabaseMock(); + mockRuntime(client); + const { POST } = await import("../src/app/api/upload/route"); + const formData = new FormData(); + formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" })); + + const response = await POST( + request("/api/upload", { + method: "POST", + body: formData, + }), + ); + + expect(response.status).toBe(503); + expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." }); + expect(client.auth.getUser).not.toHaveBeenCalled(); + expect(client.storageMocks.upload).not.toHaveBeenCalled(); + }); + + it("uploads anonymous documents to the configured public workspace owner", async () => { + const publicOwnerId = "99999999-9999-4999-8999-999999999999"; + const client = createSupabaseMock((call) => { + if (call.table === "documents" && call.operation === "select" && call.maybeSingle) return ok(null); + if (call.table === "documents" && call.operation === "insert") { + const inserted = call.insertPayload as { id: string; owner_id: string; storage_path: string }; + return ok({ id: inserted.id, owner_id: inserted.owner_id, storage_path: inserted.storage_path }); + } + if (call.table === "ingestion_jobs" && call.operation === "insert") return ok({ id: "job-1", document_id: documentId }); + return ok([]); + }); + mockRuntime(client, undefined, { publicUploadsEnabled: true, publicWorkspaceOwnerId: publicOwnerId }); + const { POST } = await import("../src/app/api/upload/route"); + const formData = new FormData(); + formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" })); + + const response = await POST( + request("/api/upload", { + method: "POST", + body: formData, + }), + ); + + expect(response.status).toBe(201); + expect(client.auth.getUser).not.toHaveBeenCalled(); + expect( + client.calls.find((call) => call.table === "documents" && call.operation === "insert")?.insertPayload, + ).toMatchObject({ + owner_id: publicOwnerId, + }); + }); + it("stores uploaded documents with owner_id and a user-scoped storage path", async () => { const client = createSupabaseMock((call) => { if (call.table === "documents" && call.operation === "insert") { @@ -2106,9 +2296,13 @@ describe("private document API access", () => { } return ok([]); }); - client.rpc.mockImplementation(async (name: string) => - name === "search_document_chunks" ? fail("missing rpc") : ok([]), - ); + client.rpc.mockImplementation(async (name: string) => { + if (name === "search_document_chunks") return fail("missing rpc"); + if (name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit") { + return { data: [rateLimitRow()], error: null }; + } + return ok([]); + }); mockRuntime(client); const { GET } = await import("../src/app/api/documents/[id]/search/route"); @@ -3003,7 +3197,9 @@ describe("private document API access", () => { })); const client = createSupabaseMock(); client.rpc.mockImplementation(async (name: string) => - name === "consume_api_rate_limit" ? fail("limiter table unavailable") : ok([]), + name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit" + ? fail("limiter table unavailable") + : ok([]), ); mockRuntime(client, { searchChunksWithTelemetry }); const { POST } = await import("../src/app/api/search/route"); diff --git a/tests/public-access-deep.test.ts b/tests/public-access-deep.test.ts new file mode 100644 index 0000000000..0801c4bd97 --- /dev/null +++ b/tests/public-access-deep.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const publicDocumentId = "11111111-1111-4111-8111-111111111111"; + +beforeEach(() => { + vi.resetModules(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); +}); + +describe("public access deep checks", () => { + it("rejects unauthenticated access to operator-only routes", async () => { + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: {} })); + const auth = { + AuthenticationError: class AuthenticationError extends Error {}, + requireAuthenticatedUser: vi.fn(async () => { + throw new auth.AuthenticationError(); + }), + unauthorizedResponse: () => Response.json({ error: "Authentication required." }, { status: 401 }), + }; + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ auth: { getUser: vi.fn() } })), + })); + vi.doMock("@/lib/supabase/auth", () => auth); + + const cases = [ + { + routePath: "../src/app/api/jobs/route", + handler: "GET" as const, + request: new Request("http://localhost/api/jobs"), + }, + { + routePath: "../src/app/api/ingestion/jobs/route", + handler: "GET" as const, + request: new Request("http://localhost/api/ingestion/jobs"), + }, + { + routePath: "../src/app/api/ingestion/batches/route", + handler: "GET" as const, + request: new Request("http://localhost/api/ingestion/batches"), + }, + { + routePath: "../src/app/api/documents/bulk/route", + handler: "POST" as const, + request: new Request("http://localhost/api/documents/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "delete", documentIds: [publicDocumentId] }), + }), + }, + { + routePath: "../src/app/api/documents/[id]/summarize/route", + handler: "POST" as const, + request: new Request(`http://localhost/api/documents/${publicDocumentId}/summarize`, { method: "POST" }), + params: { id: publicDocumentId }, + }, + { + routePath: "../src/app/api/eval-cases/route", + handler: "POST" as const, + request: new Request("http://localhost/api/eval-cases", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: "What monitoring is needed?", + rating: "good", + answer: "Monitor FBC.", + queryMode: "auto", + queryClass: "table_threshold", + }), + }), + }, + ] as const; + + for (const testCase of cases) { + vi.resetModules(); + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: {} })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ auth: { getUser: vi.fn() } })), + })); + vi.doMock("@/lib/supabase/auth", () => auth); + const mod = await import(testCase.routePath); + const handler = mod[testCase.handler]; + const response = await handler( + testCase.request, + "params" in testCase ? { params: Promise.resolve(testCase.params) } : undefined, + ); + expect(response.status, testCase.routePath).toBe(401); + } + }); + + it("does not expose secret values from the health endpoint", async () => { + vi.doMock("@/lib/env", () => ({ + env: { + NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", + SUPABASE_SERVICE_ROLE_KEY: "super-secret-service-role-key", + OPENAI_API_KEY: "sk-super-secret-openai-key", + }, + isDemoMode: () => false, + })); + const { GET } = await import("../src/app/api/health/route"); + const response = await GET(new Request("https://clinical.example/api/health?deep=1")); + const body = await response.json(); + const serialized = JSON.stringify(body); + + expect(serialized).not.toContain("super-secret-service-role-key"); + expect(serialized).not.toContain("sk-super-secret-openai-key"); + expect(body.checks.supabaseConfig).toBe("ok"); + expect(body.checks.openaiConfig).toBe("ok"); + }); + +}); + +describe("production anonymous retrieval scope", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("fails closed in production when anonymous global retrieval omits owner scope", async () => { + vi.doUnmock("@/lib/rag"); + vi.stubEnv("NODE_ENV", "production"); + vi.doMock("@/lib/env", () => ({ + env: { + OPENAI_API_KEY: "sk-test", + RAG_PROVIDER_MODE: "offline", + RAG_SEARCH_CACHE_TTL_MS: 0, + RAG_ANSWER_CACHE_TTL_MS: 0, + }, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + requestedOpenAIAnswerModels: () => ({ fast: "gpt-test", strong: "gpt-test" }), + })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + is: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + })), + })), + })), + })), + rpc: vi.fn(async () => ({ data: [], error: null })), + })), + })); + + const { searchChunksWithTelemetry } = await import("../src/lib/rag"); + + await expect( + searchChunksWithTelemetry({ + query: "clozapine monitoring", + allowGlobalSearch: true, + }), + ).rejects.toThrow(/ownerId|tenant/i); + }); +}); + +describe("test-runtime anonymous retrieval scope", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("allows anonymous global retrieval in test runtime where owner scope stays permissive", async () => { + vi.doUnmock("@/lib/rag"); + vi.doMock("@/lib/env", () => ({ + env: { + OPENAI_API_KEY: undefined, + RAG_PROVIDER_MODE: "offline", + RAG_SEARCH_CACHE_TTL_MS: 0, + RAG_ANSWER_CACHE_TTL_MS: 0, + }, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + requestedOpenAIAnswerModels: () => ({ fast: "gpt-test", strong: "gpt-test" }), + })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + is: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + in: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + })), + })), + })), + })), + })), + rpc: vi.fn(async () => ({ data: [], error: null })), + })), + })); + + const { searchChunksWithTelemetry } = await import("../src/lib/rag"); + const result = await searchChunksWithTelemetry({ + query: "clozapine monitoring", + allowGlobalSearch: true, + }); + + expect(result.results).toEqual([]); + expect(result.telemetry).toBeDefined(); + }); +}); diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts index ddb62411e1..a2f06474e1 100644 --- a/tests/retrieval-query-variants.test.ts +++ b/tests/retrieval-query-variants.test.ts @@ -127,6 +127,18 @@ describe("retrieval query variants", () => { expect(textCandidateBudgetForQueryClass("unsupported_or_general", 12)).toBe(24); }); + it("keeps forced-embedding retrieval out of the ordinary search cache key", () => { + const baseArgs = { + query: "How is panic disorder managed?", + topK: 8, + minSimilarity: 0.12, + }; + + expect(retrievalPlanCacheQuery(baseArgs, "broad_summary")).not.toBe( + retrievalPlanCacheQuery({ ...baseArgs, forceEmbedding: true }, "broad_summary"), + ); + }); + it("allows direct document title hits to skip embedding retrieval", () => { expect( decideTextFastPath( diff --git a/tests/setup-status-route.test.ts b/tests/setup-status-route.test.ts index 170aac7f5b..f952b2ef88 100644 --- a/tests/setup-status-route.test.ts +++ b/tests/setup-status-route.test.ts @@ -7,10 +7,13 @@ afterEach(() => { }); describe("/api/setup-status", () => { - it("requires auth for non-local production requests before returning setup posture", async () => { + it("returns setup posture for anonymous production requests without exposing secret values", async () => { vi.stubEnv("NODE_ENV", "production"); - const getUser = vi.fn(); - const createAdminClient = vi.fn(() => ({ auth: { getUser } })); + const from = vi.fn(async () => ({ error: null, data: [], count: 0 })); + const createAdminClient = vi.fn(() => ({ + from, + rpc: vi.fn(), + })); vi.doMock("@/lib/env", () => ({ env: { NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", @@ -24,16 +27,29 @@ describe("/api/setup-status", () => { isLocalNoAuthMode: () => false, })); vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient })); + vi.doMock("@/lib/supabase/health", () => ({ + probeSupabaseHealth: vi.fn(async () => ({ ok: true })), + isSupabaseUnavailableError: () => false, + formatSupabaseUnavailableError: (error: unknown) => String(error), + })); + vi.doMock("@/lib/supabase/project", () => ({ + checkSupabaseProjectConfig: () => ({ status: "ready", detail: "Clinical KB Database target is configured." }), + formatSupabaseProjectCheck: () => "Clinical KB Database target is configured.", + })); const { GET } = await import("../src/app/api/setup-status/route"); const response = await GET(new Request("https://clinical.example/api/setup-status")); const body = await response.json(); - expect(response.status).toBe(401); - expect(body).toEqual({ error: "Authentication required." }); - expect(JSON.stringify(body)).not.toContain("OPENAI"); - expect(JSON.stringify(body)).not.toContain("Supabase"); - expect(createAdminClient).toHaveBeenCalledTimes(1); - expect(getUser).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(body).toMatchObject({ + demoMode: false, + checks: expect.arrayContaining([ + expect.objectContaining({ id: "env" }), + expect.objectContaining({ id: "openai" }), + ]), + }); + expect(JSON.stringify(body)).not.toContain("service-role-key"); + expect(JSON.stringify(body)).not.toContain("openai-key"); }); }); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 3d2bae636a..2eb32ebbf8 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -143,6 +143,7 @@ async function mockLocalProjectIdentity(page: Page) { } async function mockPrivateUnauthenticatedApi(page: Page) { + await mockLocalProjectIdentity(page); await page.route("**/api/setup-status**", async (route) => { await route.fulfill({ json: { demoMode: false, checks: readySetupChecks }, @@ -706,6 +707,36 @@ test.describe("Clinical KB UI smoke coverage", () => { }); } + test("anonymous user can see enabled live search without a forced sign-in gate", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await mockPrivateUnauthenticatedApi(page); + await page.route(/\/api\/search(?:\?.*)?$/, async (route) => { + await route.fulfill({ json: { results: [], telemetry: { retrieval_strategy: "text_fast_path" } } }); + }); + await gotoApp(page, "/"); + await waitForDemoDashboardReady(page); + + await expect(page.getByText("Create your Clinical Guide account")).toHaveCount(0); + await expect(page.getByText("Search request was not authorized by the server.")).toHaveCount(0); + await expect(page.getByTestId("global-search-input")).toBeEnabled(); + }); + + test("anonymous mobile user can search without a forced sign-in gate", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 820 }); + await mockPrivateUnauthenticatedApi(page); + await page.route(/\/api\/search(?:\?.*)?$/, async (route) => { + await route.fulfill({ json: { results: [], telemetry: { retrieval_strategy: "text_fast_path" } } }); + }); + await gotoApp(page, "/"); + await waitForDemoDashboardReady(page); + + await expect(page.getByText("Create your Clinical Guide account")).toHaveCount(0); + await expect(page.getByText("Service unavailable")).toHaveCount(0); + await expect(page.getByText("API unavailable")).toHaveCount(0); + await expect(page.getByText("Search request was not authorized by the server.")).toHaveCount(0); + await expect(page.getByTestId("global-search-input")).toBeEnabled(); + }); + test("desktop sidebar mode sync and accessibility affordances stay coherent", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockDemoApi(page); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index ef9823b7d5..3037cfaa43 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -524,12 +524,13 @@ test.describe("Clinical KB applications launcher", () => { test("mode home deep links preserve focus=1 on initial load", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); - for (const path of ["/services?focus=1", "/forms?focus=1"]) { - await gotoLauncher(page, path); - const sharedSearch = page.getByTestId("global-search-input"); - await expect(sharedSearch).toBeVisible(); - await expect(sharedSearch).toBeFocused(); - } + await gotoLauncher(page, "/services?focus=1"); + await expect(page.getByTestId("services-home").getByTestId("global-search-input")).toBeVisible(); + await expect(page.getByTestId("services-home").getByTestId("global-search-input")).toBeFocused(); + + await gotoLauncher(page, "/forms?focus=1"); + await expect(page.getByTestId("forms-home").getByTestId("global-search-input")).toBeVisible(); + await expect(page.getByTestId("forms-home").getByTestId("global-search-input")).toBeFocused(); }); test("services mode shows source-backed records in search results", async ({ page }) => { @@ -573,11 +574,12 @@ test.describe("Clinical KB applications launcher", () => { test("form detail pages keep the shared forms search wired to form results", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await gotoLauncher(page, "/forms/transport-crisis-form"); + await expect(page.getByTestId("form-detail-page")).toBeVisible(); // Structural coverage — runs on every browser, WebKit included: the form // detail page renders inside the shared shell with the Forms-mode composer // present and no stale results. - await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible({ timeout: 20_000 }); await expect(page.getByRole("heading", { level: 1, name: "Transport order" })).toBeVisible(); await expect(page.getByTestId("form-search-results")).toHaveCount(0); const formsSearchInput = page.locator('input[placeholder="Search forms..."]:visible').first();