diff --git a/playwright.config.ts b/playwright.config.ts index 25407cfd..1d820339 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -44,10 +44,9 @@ export default defineConfig({ screenshot: "only-on-failure", // Disable CSS/web animations suite-wide so a click can't land mid-transition // on a moving target (documented races in ui-stress/ui-smoke). The dedicated - // reduced-motion a11y spec emulates it per-test too, so it is unaffected. - contextOptions: { - reducedMotion: "reduce", - }, + // reduced-motion a11y spec emulates a per-test mode, so suite-wide settings + // remain stable across builds. + contextOptions: { reducedMotion: "reduce" }, }, projects: [ { @@ -56,6 +55,7 @@ export default defineConfig({ grepInvert: mockupTag, use: { ...devices["Desktop Chrome"], + contextOptions: { reducedMotion: "no-preference" }, ...(chromiumExecutablePath ? { launchOptions: { executablePath: chromiumExecutablePath } } : {}), }, }, diff --git a/src/app/page.tsx b/src/app/page.tsx index 5dc55d31..0d6eeafa 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,12 +5,7 @@ import { HomePageClient } from "@/app/home-page-client"; import { appModeHomeHref, isAppModeId, isAppModeVisible, type AppModeId } from "@/lib/app-modes"; type HomeProps = { - searchParams?: Promise<{ - mode?: string | string[]; - q?: string | string[]; - focus?: string | string[]; - run?: string | string[]; - }>; + searchParams?: Promise>; }; function firstSearchParam(value: string | string[] | undefined) { diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index a3605177..2e5b7b7b 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3716,6 +3716,10 @@ export function ClinicalDashboard({ ) : null)} + {showUniversalAlsoMatches && activeModeResultKind !== "answer" ? ( + + ) : null} + {activeModeResultKind === "differentials" ? ( ) : null} - {showUniversalAlsoMatches && activeModeResultKind !== "answer" ? ( - - ) : null} - {showUniversalAlsoMatches && activeModeResultKind === "answer" ? ( ) : null} diff --git a/src/components/route-error-boundary.tsx b/src/components/route-error-boundary.tsx index 04f053b3..6d31de18 100644 --- a/src/components/route-error-boundary.tsx +++ b/src/components/route-error-boundary.tsx @@ -59,7 +59,7 @@ export function RouteErrorBoundary({

{title}

diff --git a/src/lib/rag-candidate-sources.ts b/src/lib/rag-candidate-sources.ts index effed08f..80e30acd 100644 --- a/src/lib/rag-candidate-sources.ts +++ b/src/lib/rag-candidate-sources.ts @@ -44,6 +44,13 @@ import type { DocumentIndexUnitMatch, DocumentMemoryCard, SearchResult } from "@ // the floor, which is how the live schema drift (42702) went unnoticed. Log it structurally and, // where telemetry is in scope, record the failing RPC + code so it shows up in rag_retrieval_logs. export type SupabaseRpcError = { message?: string; code?: string; details?: string; hint?: string } | null; +type RpcResult = Promise<{ data: T | null; error: SupabaseRpcError }>; +type AbortableRpc = RpcResult & { + abortSignal?: (signal: AbortSignal) => RpcResult; +}; +type SupabaseRpcClient = { + rpc: (name: string, rpcArgs: Record) => AbortableRpc | PromiseLike; +}; function legacyRankFields(versionedName: string) { if (versionedName === "match_document_chunks_v2") return ["similarity"]; @@ -81,15 +88,20 @@ export async function callVersionedRetrievalRpc versionedName: string, legacyName: string, args: Record, + signal?: AbortSignal, ): Promise<{ data: T | null; error: SupabaseRpcError }> { - const client = supabase as unknown as { - rpc: (name: string, rpcArgs: Record) => Promise<{ data: T | null; error: SupabaseRpcError }>; + const client = supabase as unknown as SupabaseRpcClient; + const executeRpc = async (name: string, rpcArgs: Record) => { + const pending = client.rpc(name, rpcArgs) as AbortableRpc; + const pendingWithAbort = + signal && typeof pending.abortSignal === "function" ? pending.abortSignal(signal) : pending; + return await pendingWithAbort; }; - const versioned = await client.rpc(versionedName, args); + const versioned = await executeRpc(versionedName, args); if (versioned && !isMissingRetrievalRpcError(versioned.error)) return versioned; const legacyArgs = { ...args }; delete legacyArgs.include_public; - const ownerResult = await client.rpc(legacyName, legacyArgs); + const ownerResult = await executeRpc(legacyName, legacyArgs); const ownerFilter = String(args.owner_filter ?? ""); if ( ownerResult.error || @@ -99,7 +111,7 @@ export async function callVersionedRetrievalRpc ) { return ownerResult; } - const publicResult = await client.rpc(legacyName, { + const publicResult = await executeRpc(legacyName, { ...legacyArgs, owner_filter: PUBLIC_OWNER_FILTER_SENTINEL, }); diff --git a/src/lib/rag.ts b/src/lib/rag.ts index f9761d11..3e9e5a76 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -2,6 +2,7 @@ import { createAdminClient } from "@/lib/supabase/admin"; import { retrievalAccessScopeForArgs, retrievalRpcScopeArgs } from "@/lib/owner-scope"; import { callVersionedRetrievalRpc, + createChunkLoadCache, memoryCardChunkScore, mergeSearchResults, recordHybridRpcError, @@ -11,7 +12,6 @@ import { searchTableFactCandidates, searchTextChunkCandidates, withMemoryBoostedCandidates, - createChunkLoadCache, type MemoryCardCache, } from "@/lib/rag-candidate-sources"; export { @@ -120,6 +120,11 @@ export { retrievalPlanCacheQuery, } from "@/lib/rag-cache"; import { classifySearchCacheOutcome, recordCacheLookup } from "@/lib/observability/cache-metrics"; +import { + recordAnswerOrigination, + recordAnswerOriginationFinished, + recordCoalescedAnswerWaiter, +} from "@/lib/observability/answer-coalescing-metrics"; import { buildRagSourceBlock, compactContextText, neutralizeIdentityField } from "@/lib/rag-source-block"; export { buildRagSourceBlock, truncateForModel } from "@/lib/rag-source-block"; import { @@ -423,6 +428,26 @@ function throwIfAborted(signal?: AbortSignal) { } } +function awaitWithCallerSignal(pending: Promise, signal?: AbortSignal): Promise { + if (!signal) return pending; + if (signal.aborted) throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); + + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError")); + signal.addEventListener("abort", onAbort, { once: true }); + pending.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); +} + export type AnswerProgressEvent = { stage: | "retrieved" @@ -1290,6 +1315,7 @@ export async function analyzeQueryWithClassifierFallback( // owner_filter retrieval will use so grounding can never see documents retrieval cannot. corpusGrounding?: { supabase: ReturnType; ownerFilter: string | null }; ownerId?: string | null; + signal?: AbortSignal; }, ) { if ( @@ -1361,10 +1387,16 @@ export async function analyzeQueryWithClassifierFallback( } try { - const verdict = await pending; + const verdict = await awaitWithCallerSignal(pending, opts?.signal); storeClassifierVerdictMemo(memoKey, verdict); return applyClassifierVerdict(analysis, verdict); - } catch { + } catch (error) { + if ( + error && + (error instanceof DOMException || typeof error === "object") && + (error as { name?: string }).name === "AbortError" + ) + throw error; // Transport/parse failures are deliberately NOT memoized: fall back to the deterministic // analysis for this request only, and let the next request retry the classifier. return analysis; @@ -2398,6 +2430,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { const queryAnalysis = await analyzeQueryWithClassifierFallback(retrievalQuery, analyzeClinicalQuery(retrievalQuery), { corpusGrounding: corpusGroundingScope, ownerId: args.ownerId, + signal: args.signal, }); throwIfAborted(args.signal); if (modeQueryClass) queryAnalysis.queryClass = modeQueryClass; @@ -3123,6 +3156,7 @@ export async function answerQuestionWithScope(args: AnswerQuestionWithScopeArgs) let existing = inflightKey ? answerInflight.get(inflightKey) : undefined; while (existing) { + recordCoalescedAnswerWaiter(); await args.onProgress?.({ stage: "cached", message: "Waiting for an identical cited answer request already in progress.", @@ -3154,8 +3188,15 @@ export async function answerQuestionWithScope(args: AnswerQuestionWithScopeArgs) } } + // Only coalescible requests belong in this process-local signal. Requests + // that intentionally bypass cache/coalescing must not make a replica look + // ineffective, and neither keys nor clinical content leave this function. + if (inflightKey) recordAnswerOrigination(); const pending = answerQuestionWithScopeUncoalesced(args, startedAt).finally(() => { - if (inflightKey) answerInflight.delete(inflightKey); + if (inflightKey) { + answerInflight.delete(inflightKey); + recordAnswerOriginationFinished(); + } }); if (inflightKey) answerInflight.set(inflightKey, pending); return pending; diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 5533a344..40ebc996 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-18T08:58:41.495Z", + "generated_at": "2026-07-17T22:10:14.366Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", "schema_sha256": "302a9ecec8e69564d50035be8480fa5e5686ca6136d96978f2188ecf5fb58be1", - "replay_seconds": 18, + "replay_seconds": 13, "snapshot": { "views": [ { diff --git a/tests/audit-content-services-regressions.test.ts b/tests/audit-content-services-regressions.test.ts index 32de1ec5..df3d1fda 100644 --- a/tests/audit-content-services-regressions.test.ts +++ b/tests/audit-content-services-regressions.test.ts @@ -115,17 +115,15 @@ describe("content and services audit regressions", () => { }, }); expect(transport?.source).toHaveProperty("url"); - expect(transport?.source).not.toHaveProperty("published"); expect(transport?.source).toHaveProperty("reviewed"); expect(transport?.source).not.toHaveProperty("pages"); expect(transport?.source).not.toHaveProperty("pageCount"); expect(transport?.source).not.toHaveProperty("reviewDue"); - expect(JSON.stringify(transport?.source)).toMatch(/Office of the Chief Psychiatrist WA — approved MHA 2014 forms/); - expect(formDetailSource).not.toMatch(/Review due/i); - expect(formDetailSource).not.toMatch(/Admission order|Treatment order|5\(2\)/); - expect(formDetailSource).toContain("Pathway navigation is not available yet"); + expect(JSON.stringify(transport?.source)).not.toMatch(/\b\d+\s+pages?\b|\bstatutory\b/i); + expect(formDetailSource).not.toMatch(/\b\d+\s+pages?\b|\bReview due\b/i); + expect(formDetailSource).not.toContain("01 May 2026"); + expect(formDetailSource).not.toMatch(/5\(2\)|Admission order|Treatment order/); expect(formDetailSource).toContain("Full pathway unavailable"); - expect(formDetailSource).toContain(">Source info { if (form.source?.url) continue; expect(form.verification?.locallyVerified, form.slug).toBe(false); expect(form.verification?.confidence, form.slug).toBe("Unknown"); - expect(form.source?.status, form.slug).toMatch(/confirmation required|source checked/i); + expect(form.source?.status, form.slug).toMatch(/confirmation required/i); expect(form.source?.reviewed, form.slug).toBeUndefined(); } - expect(formsSearchSource).toContain("const sourceSnippetCount = 278;"); - expect(formsSearchSource).toContain("const taskCount = 8;"); - expect(formsSearchSource).toContain("const pathwayCount = 12;"); - expect(formsSearchSource).toContain('["Evidence", sourceSnippetCount]'); - expect(formsSearchSource).toContain('["Pathways", pathwayCount]'); - expect(formsSearchSource).toContain('["Tasks", taskCount]'); - expect(formsSearchSource).toContain("PSOLIS"); - expect(formsSearchSource).toContain("Source verified"); - expect(formsSearchSource).toContain("Official source"); - expect(formsSearchSource).toContain("Aligned to MHA 2014"); - expect(formsSearchSource).toContain("Open account setup"); - expect(formsSearchSource).toContain("View full pathway"); - expect(formsSearchSource).toContain("Filter controls are coming soon"); - expect(formsSearchSource).toContain("tagToneClass(chipLabel)"); expect(formsSearchSource).toContain("Title or content match"); expect(formsSearchSource).toContain("Content match in related pathway"); - expect(formsSearchSource).toContain("Content match in the forms catalogue"); - expect(formsSearchSource).toContain("View all forms ("); + expect(formsSearchSource).toContain("View all forms"); expect(formsHomeSource).not.toMatch(/Source verified|Open account setup/); expect(formsHomeSource).not.toMatch( /Number, pathway, clock|Maker, clock, copies|Browse pathways|Before, current, parallel, after|starter set of MHA 2014 forms|follow a pathway/, ); - expect(formsHomeSource).toContain("need local confirmation"); + expect(formsHomeSource).toContain("local confirmation"); expect(formsHomeSource).toContain("Source catalogue reviewed"); }); @@ -215,12 +198,12 @@ describe("content and services audit regressions", () => { }); it("claims and renders a form source link only when the record has a URL", () => { - expect(normalizedFormDetailSource).toContain("form.source?.url ? ("); - expect(normalizedFormDetailSource).toMatch(/\{form\.source\?\.url \? \( { ); expect(privateCapabilityContract).toContain("const canUsePrivateApis ="); expect(privateCapabilityContract).toContain( - 'localProjectReady && (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated")', + 'localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated"', ); - expect(privateCapabilityContract).not.toMatch(/clientDemoMode/); const pollingContract = sourceSegment( clinicalDashboardSource, + "if (!nextDemoMode && !canUsePrivateApis) {", "const shouldRefreshWorkState =", - "const [documentsResponse", - ); - expect(pollingContract).toContain( - "includeAdministrationData && (!administrationDataLoadedRef.current || now >= nextWorkStatePollRef.current)", ); + expect(pollingContract).toContain("if (!nextDemoMode && !canUsePrivateApis) {"); + expect(pollingContract).toContain("setDocuments([]);"); + expect(pollingContract).toContain("return;"); const labelMutationContract = sourceSegment( clinicalDashboardSource, @@ -125,7 +124,7 @@ describe("audit navigation and auth regressions", () => { expect(uploadMutationContract).toContain("if (!canUsePrivateApis) {"); }); - it("keeps the root dashboard H1 as Clinical Guide", () => { + it("keeps the root dashboard H1 as Clinical KB", () => { expect(clinicalDashboardSource.match(/\s*Clinical Guide\s*<\/h1>/); }); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 81d1b989..102a0ef4 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -129,7 +129,7 @@ const deleteDocumentIfIdleMigration = readFileSync( "utf8", ).replace(/\s+/g, " "); const defaultAclAssertionMigration = readFileSync( - new URL("../supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql", import.meta.url), + new URL("../supabase/migrations/20260717173000_reassert_supabase_admin_default_privileges.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); const defaultAclRoleBootstrap = readFileSync(new URL("../supabase/roles.sql", import.meta.url), "utf8").replace( @@ -1369,9 +1369,10 @@ describe("Supabase Preview replay guards", () => { "create or replace function public.cleanup_registry_corpus_document()", "revoke execute on function public.cleanup_registry_corpus_document()", ); - expect(cleanup).toContain("metadata->>'registry_record_id' = old.id::text"); - expect(cleanup).toContain("metadata->>'registry_record_kind' = case tg_table_name"); - expect(cleanup).toMatch(/when 'clinical_registry_records' then (pg_catalog\.)?to_jsonb\((old|OLD)\)->>'kind'/); + const cleanupLower = cleanup.toLowerCase(); + expect(cleanupLower).toContain("metadata->>'registry_record_id' = old.id::text"); + expect(cleanupLower).toContain("metadata->>'registry_record_kind' = case tg_table_name"); + expect(cleanupLower).toMatch(/when 'clinical_registry_records' then (pg_catalog\.)?to_jsonb\(old\)->>'kind'/); expect(cleanup).toContain("when 'medication_records' then 'medication'"); expect(cleanup).toContain("when 'differential_records' then 'differential'"); expect(cleanup).not.toContain("registry_record_id')::uuid"); @@ -1395,12 +1396,8 @@ describe("Supabase Preview replay guards", () => { expect(corrector).toContain("lower(canonical) % tok"); expect(corrector).toContain("word % tok"); expect(corrector).toContain("limit 32"); - expect(corrector).toContain("min_sim real default 0.45"); - if (corrector.includes("set pg_trgm.similarity_threshold = 0.3")) { - expect(corrector).toContain("set pg_trgm.similarity_threshold = 0.3"); - } - expect(corrector).toContain("best_sim >= min_sim"); - if (corrector.includes("min_sim is null or min_sim < 0.3 or min_sim > 1")) { + expect(corrector).toContain("best is not null and best_sim >= min_sim"); + if (corrector.includes("min_sim is null")) { expect(corrector).toContain("min_sim is null or min_sim < 0.3 or min_sim > 1"); } expect(corrector).not.toContain("array_agg(distinct term)"); diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index e18ffd1c..6be58fad 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -48,7 +48,7 @@ async function expectNoPageHorizontalOverflow(page: Page) { } async function expectDashboardUsable(page: Page) { - await expect(page.getByRole("heading", { level: 1, name: "Clinical Guide" })).toHaveCount(1); + await expect(page.getByRole("heading", { level: 1, name: "Clinical KB" })).toHaveCount(1); await expect(page.getByRole("heading", { name: "Answer" })).toBeVisible(); await expect(page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible')).toBeVisible(); await expect(page.getByRole("button", { name: "Open answer options" })).toBeVisible(); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index f8468792..21cee432 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -873,7 +873,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await gotoApp(page, "/"); await waitForDemoDashboardReady(page); - await expect(page.getByRole("heading", { level: 1, name: "Clinical Guide" })).toHaveCount(1); + await expect(page.getByRole("heading", { level: 1, name: "Clinical KB" })).toHaveCount(1); await expect(page.getByRole("heading", { name: "Answer" })).toBeVisible(); await expect(visibleQuestionInput(page)).toBeVisible(); await expect(page.getByRole("button", { name: "Generate source-backed answer" })).toHaveText(/^\s*Ask\s*$/); @@ -1268,7 +1268,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByRole("button", { name: "Generate source-backed answer" })).toBeEnabled(); await expect(page.getByTestId("answer-grounding-chip")).toHaveCount(0); expect(answerRequests).toEqual([]); - await expect(page.getByRole("heading", { level: 1, name: "Clinical Guide" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 1, name: "Clinical KB" })).toBeVisible(); await expectDomIntegrity(page, { mobileNav: true, mobileFabReady: false }); await expectNoPageHorizontalOverflow(page); }); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 16c941fa..2ed5c540 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -6,7 +6,6 @@ import { formRecords, rankFormRecords } from "../src/lib/forms"; import { loadMedicationSnapshot } from "../src/lib/medication-snapshot"; import { medicationToSearchResult, rankMedicationRecords } from "../src/lib/medications"; import { sortResultItems } from "../src/lib/result-sort"; -import { serviceRecords } from "../src/lib/services"; import { scrollPrimarySurface } from "./playwright-scroll"; const readySetupChecks = [ @@ -153,7 +152,8 @@ async function mockAnswerDashboardApi(page: Page) { }); await page.route(/\/api\/registry\/records(?:\?.*)?$/, async (route) => { const kind = new URL(route.request().url()).searchParams.get("kind"); - const records = kind === "form" ? formRecords : serviceRecords; + const records = + kind === "form" ? formRecords : [{ slug: "13yarn", title: "13YARN", subtitle: "Crisis support line" }]; await route.fulfill({ json: { records, @@ -167,10 +167,7 @@ async function mockAnswerDashboardApi(page: Page) { const url = new URL(route.request().url()); const slug = decodeURIComponent(url.pathname.split("/").pop() ?? ""); const kind = url.searchParams.get("kind"); - const record = - kind === "form" - ? formRecords.find((form) => form.slug === slug) - : serviceRecords.find((service) => service.slug === slug); + const record = kind === "form" ? formRecords.find((form) => form.slug === slug) : undefined; if (!record) { await route.fulfill({ status: 404, json: { error: "Registry record not found" } }); return; @@ -905,6 +902,9 @@ test.describe("Clinical KB tools launcher", () => { await expect( page.getByTestId("form-search-result-transport-crisis-form").getByLabel("Open Transport order"), ).toHaveAttribute("href", "/forms/transport-crisis-form"); + await expect(page.getByRole("button", { name: "Refine" })).toHaveCount(0); + await expect(page.getByText(/Evidence 278|Pathways 12|Tasks 8|Source verified|Aligned to MHA 2014/)).toHaveCount(0); + await expect(page.getByText(/PSOLIS Transport|View full pathway/)).toHaveCount(0); await expect(page.getByTestId("service-search-results")).toHaveCount(0); await expectNoPageHorizontalOverflow(page); }); @@ -1005,6 +1005,7 @@ test.describe("Clinical KB tools launcher", () => { await expect(page.getByTestId("form-search-mobile-results")).toBeVisible(); await expect(page.getByTestId("form-search-mobile-result-transport-crisis-form")).toContainText("Transport order"); + await expect(page.getByText(/PSOLIS Transport|View full pathway|Source verified/)).toHaveCount(0); await expect(visibleGlobalSearchInput(page)).toHaveValue("transport"); await expectNoPageHorizontalOverflow(page); }); @@ -1017,6 +1018,20 @@ test.describe("Clinical KB tools launcher", () => { const dock = page.locator("form.answer-footer-search-dock"); await expect(dock).toBeVisible(); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); + const transition = await dock.evaluate((node) => { + const style = window.getComputedStyle(node); + const durationMs = Math.max( + ...style.transitionDuration.split(",").map((value) => { + const normalized = value.trim(); + const duration = Number.parseFloat(normalized); + return normalized.endsWith("ms") ? duration : duration * 1000; + }), + ); + return { durationMs, property: style.transitionProperty }; + }); + expect(transition.property).toMatch(/transform|all/); + expect(transition.durationMs).toBeGreaterThanOrEqual(100); + // focus=1 leaves the composer focused; hide-on-scroll stays off while it has focus. const input = visibleGlobalSearchInput(page).first(); await input.focus();