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/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx index d1d6a40e..f2ef35b9 100644 --- a/src/components/forms/forms-search-results-page.tsx +++ b/src/components/forms/forms-search-results-page.tsx @@ -46,9 +46,7 @@ type FormsSearchResultsPageProps = { query: string; }; -const sourceSnippetCount = 278; -const taskCount = 8; -const pathwayCount = 12; +const supportsPathwayClaims = false; const refineFilters: { icon: LucideIcon; @@ -94,9 +92,6 @@ function ResultTabs({ formsCount }: { formsCount: number }) { const tabs = [ ["Results", null], ["Forms", formsCount], - ["Evidence", sourceSnippetCount], - ["Pathways", pathwayCount], - ["Tasks", taskCount], ] as const; return ( @@ -669,14 +664,16 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) {
- setRefineOpen((open) => !open)} - panelId={refinePanelId} - /> + {supportsPathwayClaims ? ( + setRefineOpen((open) => !open)} + panelId={refinePanelId} + /> + ) : null}
- + {supportsPathwayClaims ? : null}
@@ -688,13 +685,9 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { ) : null} -
- -
-
- -
- {registryReady ? : null} +
{supportsPathwayClaims ? : null}
+
{supportsPathwayClaims ? : null}
+ {registryReady && supportsPathwayClaims ? : null} ); 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..695837d9 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,17 @@ 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 (opts?.signal?.aborted) throw opts.signal.reason ?? 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 +2431,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; @@ -2813,6 +2847,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { document_filters: documentFilterList ?? undefined, ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, + args.signal, ); return { data, error, latencyMs: Date.now() - startedAt }; })(), @@ -2920,12 +2955,21 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { document_filter: documentFilter ?? undefined, ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, + args.signal, ); if (error) throw new Error(error.message); return (data ?? []) as SearchResult[]; }), ).catch((error) => { + if (args.signal?.aborted) throw args.signal.reason ?? error; + if ( + error && + (error instanceof DOMException || typeof error === "object") && + (error as { name?: string }).name === "AbortError" + ) { + throw error; + } if (!args.forceEmbedding && textFastResults.length > 0) return [] as SearchResult[][]; throw error; }); @@ -3123,6 +3167,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 +3199,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/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, 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-tools.spec.ts b/tests/ui-tools.spec.ts index 16c941fa..34f8faed 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -5,8 +5,8 @@ import { demoAnswer, demoDocuments } from "../src/lib/demo-data"; 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 { sortResultItems } from "../src/lib/result-sort"; import { scrollPrimarySurface } from "./playwright-scroll"; const readySetupChecks = [ @@ -905,6 +905,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,18 +1008,41 @@ 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); }); test("phone bottom search dock hides while scrolling down on search results", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); + // Suite-wide reducedMotion collapses transition durations; this case asserts the + // non-reduced dock hide animation, so restore motion for this test only. + await page.emulateMedia({ reducedMotion: "no-preference" }); await gotoLauncher(page, "/forms?q=transport&focus=1&run=1"); await expect(page.getByTestId("form-search-mobile-results")).toBeVisible(); const dock = page.locator("form.answer-footer-search-dock"); await expect(dock).toBeVisible(); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); + // The global Playwright config emulates prefers-reduced-motion:reduce to + // avoid animation races. Temporarily lift it so motion-reduce:transition-none + // does not suppress the transition we are about to verify. + await page.emulateMedia({ reducedMotion: "no-preference" }); + 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 }; + }); + await page.emulateMedia({ reducedMotion: "reduce" }); + 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();