From 89918894a5275d350f9280deb05e91489c46e3d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 10:01:44 +0000 Subject: [PATCH 1/3] Extract pure helpers from ClinicalDashboard into a tested module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move ~12 pure, module-scope helpers out of the 4k-line ClinicalDashboard component into src/components/clinical-dashboard/clinical-dashboard-helpers.ts so the domain logic can be unit-tested directly (issue #51 — centralise domain functions into reusable, tested modules). Verbatim moves, zero behaviour change: - Polling: normalizedPollDelay, shorterPollDelay, hasActiveIndexingWork, setupNeedsSlowRecheck (+ the shared setupRecheckPollMs constant) - Document refresh: answerReferencesDocument, applyRenamedDocumentToAnswer, mergeDocumentRefresh - Misc pure: compactScopeFilters, hasNonProductionSupabaseApiKeyFallback, normalizeNavigationHash, isAbortError, answerTimedOutError ClinicalDashboard now imports them; orphaned imports removed. Adds tests/clinical-dashboard-helpers.test.ts (12 tests) covering poll-delay clamping, indexing/recheck detection, merge ordering + label/summary fallbacks, hash normalisation, abort detection, and answer document reference/rename. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JBemWnYhqdGXBzwJUTQAqe --- src/components/ClinicalDashboard.tsx | 154 ++------------- .../clinical-dashboard-helpers.ts | 153 +++++++++++++++ tests/clinical-dashboard-helpers.test.ts | 183 ++++++++++++++++++ 3 files changed, 350 insertions(+), 140 deletions(-) create mode 100644 src/components/clinical-dashboard/clinical-dashboard-helpers.ts create mode 100644 tests/clinical-dashboard-helpers.test.ts diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 016dc9373..133487d5e 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -82,13 +82,26 @@ import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/ import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; +import { + answerReferencesDocument, + answerTimedOutError, + applyRenamedDocumentToAnswer, + compactScopeFilters, + hasActiveIndexingWork, + hasNonProductionSupabaseApiKeyFallback, + isAbortError, + mergeDocumentRefresh, + normalizeNavigationHash, + setupNeedsSlowRecheck, + setupRecheckPollMs, + shorterPollDelay, +} from "@/components/clinical-dashboard/clinical-dashboard-helpers"; import { answerRecovery, errorCopy } from "@/lib/ui-copy"; import { type DocumentDrawerMode, type DocumentDrawerStatusFilter, type DocumentPagination, type LabelReviewMutationBody, - navigationHashes, recentQueryStorageKey, } from "@/components/clinical-dashboard/dashboard-contracts"; @@ -196,7 +209,6 @@ import type { QuoteCard, RagAnswer, AnswerSection, - RelatedDocument, SearchResult, SearchScopeSummary, ClinicalQueryMode, @@ -209,7 +221,6 @@ import { createQuoteFollowUp, type AnswerViewMode, shouldPollForUpdates } from " const documentPageSize = 150; const activeIndexingPollFallbackMs = 5_000; -const setupRecheckPollMs = 60_000; const indexingWorkDetailsPollMs = 15_000; const stagedDashboardExtraction = { answerSurface: true, @@ -268,31 +279,6 @@ export const clinicalQueryModeOptions: Array<{ value: ClinicalQueryMode; label: { value: "compare_guidance", label: "Compare" }, ]; -function compactScopeFilters(filters: SearchScopeFilters) { - const next: SearchScopeFilters = {}; - if (filters.medications?.length) next.medications = filters.medications; - if (filters.topics?.length) next.topics = filters.topics; - if (filters.documentTypes?.length) next.documentTypes = filters.documentTypes; - if (filters.sites?.length) next.sites = filters.sites; - if (filters.services?.length) next.services = filters.services; - if (filters.settings?.length) next.settings = filters.settings; - if (filters.populations?.length) next.populations = filters.populations; - if (filters.risks?.length) next.risks = filters.risks; - if (filters.workflows?.length) next.workflows = filters.workflows; - if (filters.clinicalActions?.length) next.clinicalActions = filters.clinicalActions; - if (filters.carePhases?.length) next.carePhases = filters.carePhases; - if (filters.documentIntents?.length) next.documentIntents = filters.documentIntents; - if (filters.contentFeatures?.length) next.contentFeatures = filters.contentFeatures; - if (filters.sourceStatuses?.length) next.sourceStatuses = filters.sourceStatuses; - if (filters.validationStatuses?.length) next.validationStatuses = filters.validationStatuses; - if (filters.extractionQualities?.length) next.extractionQualities = filters.extractionQualities; - if (filters.locality) next.locality = filters.locality; - if (filters.importBatchIds?.length) next.importBatchIds = filters.importBatchIds; - if (filters.collections?.length) next.collections = filters.collections; - if (filters.labelTypesAny?.length) next.labelTypesAny = filters.labelTypesAny; - return next; -} - type SearchResultModePayload = | { kind: "documents"; @@ -313,26 +299,6 @@ type SearchResultModePayload = type SourceLibrarySearchMode = Extract; -function hasNonProductionSupabaseApiKeyFallback(checks: SetupCheck[]) { - return ( - process.env.NODE_ENV !== "production" && - checks.some( - (check) => - check.id === "search" && - check.status !== "ready" && - /\b(?:unregistered|invalid)\s+api\s+key\b/i.test(check.detail), - ) - ); -} - -/** True when an error originates from an AbortController (user pressed Stop / component unmounted). */ -function isAbortError(error: unknown): boolean { - return error instanceof DOMException && error.name === "AbortError"; -} - -function normalizeNavigationHash(hash: string) { - return navigationHashes.includes(hash as (typeof navigationHashes)[number]) ? hash : "#search"; -} /** * 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. @@ -346,16 +312,6 @@ type AnswerTurn = { const maxVisiblePriorTurns = 10; -// Non-retryable so an aborted request does not immediately re-fetch against the -// already-aborted signal; the user re-submits to try again. Raised by the -// stall watchdog (see createAnswerRequestWatchdog): a live stream that keeps -// delivering progress/heartbeat bytes is never aborted, no matter how -// long a fast->strong escalation takes, so this now only appears when the -// stream genuinely went silent or hit the absolute ceiling. -function answerTimedOutError() { - return makeSearchError("Answer generation timed out. Please try again.", 408, false); -} - /** * Renders a collapsible, read-only view of a previous answer-thread turn with its question, answer, sources, and source-review notice. * @@ -459,88 +415,6 @@ type LibraryHealthTarget = "documents" | "setup" | "indexing" | "failures"; type IndexingMonitorFilter = "all" | "active" | "failed"; type UploadIndexingTab = "setup" | "upload" | "jobs" | "quality"; -function answerReferencesDocument(answer: RagAnswer | null, documentId: string) { - if (!answer) return false; - return ( - answer.citations.some((citation) => citation.document_id === documentId) || - answer.sources.some((source) => source.document_id === documentId) || - Boolean(answer.bestSource?.document_id === documentId) || - Boolean(answer.relatedDocuments?.some((document) => document.document_id === documentId)) || - Boolean(answer.visualEvidence?.some((image) => image.document_id === documentId)) - ); -} - -function applyRenamedDocumentToAnswer(answer: RagAnswer | null, document: ClinicalDocument) { - if (!answer || !answerReferencesDocument(answer, document.id)) return answer; - const renameCitation = (item: T): T => - item.document_id === document.id ? { ...item, title: document.title } : item; - const renameRelated = (item: RelatedDocument): RelatedDocument => - item.document_id === document.id ? { ...item, title: document.title } : item; - - return { - ...answer, - citations: answer.citations.map(renameCitation), - quoteCards: answer.quoteCards?.map(renameCitation), - sources: answer.sources.map(renameCitation), - visualEvidence: answer.visualEvidence?.map(renameCitation), - bestSource: answer.bestSource ? renameCitation(answer.bestSource) : answer.bestSource, - relatedDocuments: answer.relatedDocuments?.map(renameRelated), - smartPanel: answer.smartPanel - ? { - ...answer.smartPanel, - bestSource: answer.smartPanel.bestSource - ? renameCitation(answer.smartPanel.bestSource) - : answer.smartPanel.bestSource, - relatedDocuments: answer.smartPanel.relatedDocuments?.map(renameRelated), - } - : answer.smartPanel, - } satisfies RagAnswer; -} - -function normalizedPollDelay(value: unknown) { - const parsed = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(parsed) || parsed <= 0) return null; - return Math.min(Math.max(parsed, 3_000), setupRecheckPollMs); -} - -function shorterPollDelay(current: number | null, next: unknown) { - const normalized = normalizedPollDelay(next); - if (!normalized) return current; - return current === null ? normalized : Math.min(current, normalized); -} - -function hasActiveIndexingWork( - documents: ClinicalDocument[], - jobs: IngestionJob[] = [], - batches: ImportBatch[] = [], - routeHint = false, -) { - return ( - routeHint || - documents.some((document) => document.status === "queued" || document.status === "processing") || - jobs.some((job) => job.status === "pending" || job.status === "processing") || - batches.some((batch) => batch.status === "queued" || batch.status === "processing") - ); -} - -function setupNeedsSlowRecheck(checks: SetupCheck[]) { - return checks.some((check) => check.status !== "ready"); -} - -function mergeDocumentRefresh(current: ClinicalDocument[], updates: ClinicalDocument[]) { - const currentById = new Map(current.map((document) => [document.id, document])); - return updates.map((document) => { - const existing = currentById.get(document.id); - if (!existing) return document; - return { - ...existing, - ...document, - labels: document.labels ?? existing.labels, - summary: document.summary ?? existing.summary, - }; - }); -} - /** * Renders the clinical search dashboard, including document search, answer generation, conversation history, source management, and ingestion controls. * diff --git a/src/components/clinical-dashboard/clinical-dashboard-helpers.ts b/src/components/clinical-dashboard/clinical-dashboard-helpers.ts new file mode 100644 index 000000000..975eace5b --- /dev/null +++ b/src/components/clinical-dashboard/clinical-dashboard-helpers.ts @@ -0,0 +1,153 @@ +// Pure domain helpers extracted from ClinicalDashboard.tsx (#51 — centralise +// domain logic into a reusable, unit-tested module). These are verbatim moves: +// behaviour is unchanged, and the module is framework-free (no React) so each +// helper can be unit-tested directly instead of only through the 4k-line +// component. + +import type { SetupCheck } from "@/components/clinical-dashboard/DocumentManagerPanel"; +import { navigationHashes } from "@/components/clinical-dashboard/dashboard-contracts"; +import { makeSearchError } from "@/components/clinical-dashboard/search-utils"; +import type { ClinicalDocument, ImportBatch, IngestionJob, RagAnswer, RelatedDocument } from "@/lib/types"; +import type { SearchScopeFilters } from "@/lib/search-scope"; + +// Poll-delay ceiling for setup re-checks; also the clamp ceiling for +// `normalizedPollDelay`. Shared with the dashboard's polling loop. +export const setupRecheckPollMs = 60_000; + +export function compactScopeFilters(filters: SearchScopeFilters) { + const next: SearchScopeFilters = {}; + if (filters.medications?.length) next.medications = filters.medications; + if (filters.topics?.length) next.topics = filters.topics; + if (filters.documentTypes?.length) next.documentTypes = filters.documentTypes; + if (filters.sites?.length) next.sites = filters.sites; + if (filters.services?.length) next.services = filters.services; + if (filters.settings?.length) next.settings = filters.settings; + if (filters.populations?.length) next.populations = filters.populations; + if (filters.risks?.length) next.risks = filters.risks; + if (filters.workflows?.length) next.workflows = filters.workflows; + if (filters.clinicalActions?.length) next.clinicalActions = filters.clinicalActions; + if (filters.carePhases?.length) next.carePhases = filters.carePhases; + if (filters.documentIntents?.length) next.documentIntents = filters.documentIntents; + if (filters.contentFeatures?.length) next.contentFeatures = filters.contentFeatures; + if (filters.sourceStatuses?.length) next.sourceStatuses = filters.sourceStatuses; + if (filters.validationStatuses?.length) next.validationStatuses = filters.validationStatuses; + if (filters.extractionQualities?.length) next.extractionQualities = filters.extractionQualities; + if (filters.locality) next.locality = filters.locality; + if (filters.importBatchIds?.length) next.importBatchIds = filters.importBatchIds; + if (filters.collections?.length) next.collections = filters.collections; + if (filters.labelTypesAny?.length) next.labelTypesAny = filters.labelTypesAny; + return next; +} + +export function hasNonProductionSupabaseApiKeyFallback(checks: SetupCheck[]) { + return ( + process.env.NODE_ENV !== "production" && + checks.some( + (check) => + check.id === "search" && + check.status !== "ready" && + /\b(?:unregistered|invalid)\s+api\s+key\b/i.test(check.detail), + ) + ); +} + +/** True when an error originates from an AbortController (user pressed Stop / component unmounted). */ +export function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} + +export function normalizeNavigationHash(hash: string) { + return navigationHashes.includes(hash as (typeof navigationHashes)[number]) ? hash : "#search"; +} + +// Non-retryable so an aborted request does not immediately re-fetch against the +// already-aborted signal; the user re-submits to try again. Raised by the +// stall watchdog (see createAnswerRequestWatchdog): a live stream that keeps +// delivering progress/heartbeat bytes is never aborted, no matter how +// long a fast->strong escalation takes, so this now only appears when the +// stream genuinely went silent or hit the absolute ceiling. +export function answerTimedOutError() { + return makeSearchError("Answer generation timed out. Please try again.", 408, false); +} + +export function answerReferencesDocument(answer: RagAnswer | null, documentId: string) { + if (!answer) return false; + return ( + answer.citations.some((citation) => citation.document_id === documentId) || + answer.sources.some((source) => source.document_id === documentId) || + Boolean(answer.bestSource?.document_id === documentId) || + Boolean(answer.relatedDocuments?.some((document) => document.document_id === documentId)) || + Boolean(answer.visualEvidence?.some((image) => image.document_id === documentId)) + ); +} + +export function applyRenamedDocumentToAnswer(answer: RagAnswer | null, document: ClinicalDocument) { + if (!answer || !answerReferencesDocument(answer, document.id)) return answer; + const renameCitation = (item: T): T => + item.document_id === document.id ? { ...item, title: document.title } : item; + const renameRelated = (item: RelatedDocument): RelatedDocument => + item.document_id === document.id ? { ...item, title: document.title } : item; + + return { + ...answer, + citations: answer.citations.map(renameCitation), + quoteCards: answer.quoteCards?.map(renameCitation), + sources: answer.sources.map(renameCitation), + visualEvidence: answer.visualEvidence?.map(renameCitation), + bestSource: answer.bestSource ? renameCitation(answer.bestSource) : answer.bestSource, + relatedDocuments: answer.relatedDocuments?.map(renameRelated), + smartPanel: answer.smartPanel + ? { + ...answer.smartPanel, + bestSource: answer.smartPanel.bestSource + ? renameCitation(answer.smartPanel.bestSource) + : answer.smartPanel.bestSource, + relatedDocuments: answer.smartPanel.relatedDocuments?.map(renameRelated), + } + : answer.smartPanel, + } satisfies RagAnswer; +} + +export function normalizedPollDelay(value: unknown) { + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return null; + return Math.min(Math.max(parsed, 3_000), setupRecheckPollMs); +} + +export function shorterPollDelay(current: number | null, next: unknown) { + const normalized = normalizedPollDelay(next); + if (!normalized) return current; + return current === null ? normalized : Math.min(current, normalized); +} + +export function hasActiveIndexingWork( + documents: ClinicalDocument[], + jobs: IngestionJob[] = [], + batches: ImportBatch[] = [], + routeHint = false, +) { + return ( + routeHint || + documents.some((document) => document.status === "queued" || document.status === "processing") || + jobs.some((job) => job.status === "pending" || job.status === "processing") || + batches.some((batch) => batch.status === "queued" || batch.status === "processing") + ); +} + +export function setupNeedsSlowRecheck(checks: SetupCheck[]) { + return checks.some((check) => check.status !== "ready"); +} + +export function mergeDocumentRefresh(current: ClinicalDocument[], updates: ClinicalDocument[]) { + const currentById = new Map(current.map((document) => [document.id, document])); + return updates.map((document) => { + const existing = currentById.get(document.id); + if (!existing) return document; + return { + ...existing, + ...document, + labels: document.labels ?? existing.labels, + summary: document.summary ?? existing.summary, + }; + }); +} diff --git a/tests/clinical-dashboard-helpers.test.ts b/tests/clinical-dashboard-helpers.test.ts new file mode 100644 index 000000000..4ae66d341 --- /dev/null +++ b/tests/clinical-dashboard-helpers.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; + +import { navigationHashes } from "@/components/clinical-dashboard/dashboard-contracts"; +import type { SetupCheck } from "@/components/clinical-dashboard/DocumentManagerPanel"; +import { + answerReferencesDocument, + answerTimedOutError, + applyRenamedDocumentToAnswer, + compactScopeFilters, + hasActiveIndexingWork, + hasNonProductionSupabaseApiKeyFallback, + isAbortError, + mergeDocumentRefresh, + normalizeNavigationHash, + normalizedPollDelay, + setupNeedsSlowRecheck, + setupRecheckPollMs, + shorterPollDelay, +} from "@/components/clinical-dashboard/clinical-dashboard-helpers"; +import type { ClinicalDocument, ImportBatch, IngestionJob, RagAnswer } from "@/lib/types"; + +function check(partial: Partial): SetupCheck { + return { id: "search", label: "Search", status: "ready", detail: "", ...partial }; +} + +function doc(partial: { + id: string; + title?: string; + status?: string; + labels?: unknown[]; + summary?: string; +}): ClinicalDocument { + return { title: "Doc", status: "indexed", ...partial } as unknown as ClinicalDocument; +} + +describe("normalizedPollDelay / shorterPollDelay", () => { + it("clamps to [3000, setupRecheckPollMs] and rejects invalid values", () => { + expect(normalizedPollDelay(1000)).toBe(3000); // floor + expect(normalizedPollDelay(5000)).toBe(5000); + expect(normalizedPollDelay(999999)).toBe(setupRecheckPollMs); // ceiling + expect(normalizedPollDelay("8000")).toBe(8000); // numeric string + expect(normalizedPollDelay(0)).toBeNull(); + expect(normalizedPollDelay(-5)).toBeNull(); + expect(normalizedPollDelay("abc")).toBeNull(); + expect(normalizedPollDelay(null)).toBeNull(); + }); + + it("keeps the shorter of current and next, ignoring invalid next", () => { + expect(shorterPollDelay(null, 5000)).toBe(5000); + expect(shorterPollDelay(10000, 5000)).toBe(5000); + expect(shorterPollDelay(4000, 8000)).toBe(4000); + expect(shorterPollDelay(10000, 0)).toBe(10000); // invalid next → keep current + expect(shorterPollDelay(null, 0)).toBeNull(); + }); +}); + +describe("hasActiveIndexingWork / setupNeedsSlowRecheck", () => { + it("detects in-flight work across documents, jobs, batches, and the route hint", () => { + expect(hasActiveIndexingWork([], [], [], true)).toBe(true); + expect(hasActiveIndexingWork([doc({ id: "a", status: "processing" })])).toBe(true); + expect(hasActiveIndexingWork([], [{ status: "pending" } as IngestionJob])).toBe(true); + expect(hasActiveIndexingWork([], [], [{ status: "queued" } as ImportBatch])).toBe(true); + expect(hasActiveIndexingWork([doc({ id: "a", status: "indexed" })], [], [])).toBe(false); + }); + + it("flags a slow recheck when any setup check is not ready", () => { + expect(setupNeedsSlowRecheck([check({ status: "ready" }), check({ id: "openai", status: "ready" })])).toBe(false); + expect(setupNeedsSlowRecheck([check({ status: "ready" }), check({ id: "openai", status: "needs_setup" })])).toBe( + true, + ); + }); +}); + +describe("hasNonProductionSupabaseApiKeyFallback", () => { + it("matches an unregistered/invalid search API key outside production", () => { + // vitest runs with NODE_ENV=test (not production), so the guard passes. + expect( + hasNonProductionSupabaseApiKeyFallback([check({ status: "needs_setup", detail: "Unregistered API key" })]), + ).toBe(true); + expect( + hasNonProductionSupabaseApiKeyFallback([check({ status: "needs_setup", detail: "invalid api key here" })]), + ).toBe(true); + expect(hasNonProductionSupabaseApiKeyFallback([check({ status: "needs_setup", detail: "network down" })])).toBe( + false, + ); + expect( + hasNonProductionSupabaseApiKeyFallback([ + check({ id: "openai", status: "needs_setup", detail: "invalid api key" }), + ]), + ).toBe(false); + expect(hasNonProductionSupabaseApiKeyFallback([check({ status: "ready", detail: "invalid api key" })])).toBe(false); + }); +}); + +describe("normalizeNavigationHash / isAbortError / answerTimedOutError", () => { + it("keeps known hashes and falls back to #search", () => { + for (const hash of navigationHashes) expect(normalizeNavigationHash(hash)).toBe(hash); + expect(normalizeNavigationHash("#unknown")).toBe("#search"); + expect(normalizeNavigationHash("")).toBe("#search"); + }); + + it("recognises AbortController errors only", () => { + expect(isAbortError(new DOMException("stop", "AbortError"))).toBe(true); + expect(isAbortError(new DOMException("boom", "InvalidStateError"))).toBe(false); + expect(isAbortError(new Error("AbortError"))).toBe(false); + expect(isAbortError("AbortError")).toBe(false); + }); + + it("builds a non-retryable 408 timeout error", () => { + const error = answerTimedOutError(); + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/timed out/i); + expect(error.status).toBe(408); + expect(error.retryable).toBe(false); + }); +}); + +describe("compactScopeFilters", () => { + it("keeps only populated array facets and truthy locality", () => { + const compacted = compactScopeFilters({ + medications: ["metformin"], + topics: [], + sites: ["ed"], + locality: "local", + collections: [], + }); + expect(compacted).toEqual({ medications: ["metformin"], sites: ["ed"], locality: "local" }); + expect(compactScopeFilters({})).toEqual({}); + }); +}); + +describe("mergeDocumentRefresh", () => { + it("merges updates over existing by id, preserves labels/summary fallbacks, and follows update order", () => { + const current = [ + doc({ id: "1", title: "Old One", labels: ["x"], summary: "old summary" }), + doc({ id: "2", title: "Two" }), + ]; + const updates = [ + doc({ id: "2", title: "Two v2" }), + doc({ id: "1", title: "New One" }), + doc({ id: "3", title: "Three" }), + ]; + const merged = mergeDocumentRefresh(current, updates); + expect(merged.map((d) => d.id)).toEqual(["2", "1", "3"]); // order follows updates + const one = merged.find((d) => d.id === "1")!; + expect(one.title).toBe("New One"); // update wins + expect(one.labels).toEqual(["x"]); // fallback to existing when update omits + expect(one.summary).toBe("old summary"); + expect(merged.find((d) => d.id === "3")!.title).toBe("Three"); // brand-new passthrough + }); +}); + +describe("answerReferencesDocument / applyRenamedDocumentToAnswer", () => { + function answer(overrides: Partial): RagAnswer { + return { + citations: [], + sources: [], + ...overrides, + } as unknown as RagAnswer; + } + + it("detects references across citations and sources, and no-ops on null", () => { + expect(answerReferencesDocument(null, "d1")).toBe(false); + expect(answerReferencesDocument(answer({ citations: [{ document_id: "d1", title: "T" }] as never }), "d1")).toBe( + true, + ); + expect(answerReferencesDocument(answer({ sources: [{ document_id: "d2" }] as never }), "d2")).toBe(true); + expect(answerReferencesDocument(answer({}), "d9")).toBe(false); + }); + + it("renames the document title everywhere it is referenced and no-ops otherwise", () => { + const original = answer({ + citations: [{ document_id: "d1", title: "Old" }] as never, + sources: [{ document_id: "d1", title: "Old" }] as never, + }); + const renamed = applyRenamedDocumentToAnswer(original, doc({ id: "d1", title: "New Title" })); + expect(renamed?.citations[0].title).toBe("New Title"); + expect(renamed?.sources[0].title).toBe("New Title"); + + const unrelated = applyRenamedDocumentToAnswer(original, doc({ id: "dX", title: "Nope" })); + expect(unrelated).toBe(original); // unchanged reference when not referenced + }); +}); From 0b3dd9681c22dd17e9a1bc841928fca3cf0c0a71 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 10:01:44 +0000 Subject: [PATCH 2/3] Add SaMD classification tracking note for medication considerations Record that the patient-info medication considerations feature (PR #620) is a new patient-specific decision-support surface whose formal TGA SaMD classification is an open decision for a human/regulatory reviewer. Captures existing mitigations and open questions; asserts no classification. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JBemWnYhqdGXBzwJUTQAqe --- ...lassification-medication-considerations.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/samd-classification-medication-considerations.md diff --git a/docs/samd-classification-medication-considerations.md b/docs/samd-classification-medication-considerations.md new file mode 100644 index 000000000..556385123 --- /dev/null +++ b/docs/samd-classification-medication-considerations.md @@ -0,0 +1,52 @@ +# SaMD classification — patient-info medication considerations + +**Status:** OPEN — awaiting human/regulatory decision. This note tracks the +consideration; it does **not** assert a classification. + +## Context + +PR #620 added a patient-info → medication considerations feature (merged to +`main`): + +- A patient-profile panel (age, renal function, hepatic severity, QTc, + pregnancy/lactation, allergy classes) on the medication detail page + (`/medications/[slug]`) and the prescribing search workspace. +- A pure evaluation engine (`src/lib/medication-patient-alerts.ts`) that matches + the entered profile against the source-backed patient-match metadata already + in `data/medications-snapshot.json` and surfaces tone-coded considerations. + +This is the app's first **patient-specific decision-support surface**: output is +tailored to individual patient parameters rather than presenting the same +reference content to everyone. + +## Why this needs a classification decision + +Software that provides patient-specific treatment/prescribing recommendations +can fall within the definition of Software as a Medical Device (SaMD) under the +Australian TGA framework (and equivalent frameworks elsewhere). Whether this +feature does depends on intended-use and claims — a regulatory/clinical +determination, not an engineering one. + +Mitigations already in the shipped feature (relevant to any assessment, not a +substitute for it): + +- Every consideration renders the source-backed `note` and a persistent + "Decision support, not medical advice" disclaimer. +- The profile is anonymous physiology only (no PHI), session-scoped, cleared on + tab close. +- Missing inputs surface as "unassessed" rather than as an all-clear — the tool + never implies a contraindication was ruled out on absent data. + +## Open questions for the reviewer + +1. Does the intended use / product claim bring this within SaMD scope for the + TGA (and any other target jurisdiction)? +2. If in scope, what classification and obligations apply, and do the current + disclaimers/UX need to change? +3. Should there be an explicit intended-use statement surfaced in-product? + +## Owner / next step + +Requires a human clinical + regulatory reviewer. Update this file with the +determination (and link any external assessment) once made; do not mark the +feature "classified" until that decision is recorded here. From b7f26c4b546da5df5fcba82a8429620550d5a7b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 10:40:50 +0000 Subject: [PATCH 3/3] Address CodeRabbit review: sync answer document-reference detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit answerReferencesDocument now checks every field applyRenamedDocumentToAnswer rewrites — quoteCards and the nested smartPanel.bestSource / smartPanel.relatedDocuments — so a document referenced only through those paths is no longer guarded out and correctly picks up a rename (previously a pre-existing latent mismatch carried over in the extraction). Adds tests for quoteCards- and smartPanel-only detection + rename. Also makes the SaMD tracking note's owner/next-step accountable: requires a named owner, a target review-by date, and a tracking issue rather than an open-ended "requires a reviewer". Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JBemWnYhqdGXBzwJUTQAqe --- ...lassification-medication-considerations.md | 15 ++++++-- .../clinical-dashboard-helpers.ts | 8 ++++- tests/clinical-dashboard-helpers.test.ts | 35 ++++++++++++++++++- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/docs/samd-classification-medication-considerations.md b/docs/samd-classification-medication-considerations.md index 556385123..dcd1a1035 100644 --- a/docs/samd-classification-medication-considerations.md +++ b/docs/samd-classification-medication-considerations.md @@ -47,6 +47,15 @@ substitute for it): ## Owner / next step -Requires a human clinical + regulatory reviewer. Update this file with the -determination (and link any external assessment) once made; do not mark the -feature "classified" until that decision is recorded here. +- **Owner:** to be assigned by the repository maintainer (a named clinical + + regulatory reviewer must be recorded here — "requires a reviewer" is not an + accountable assignment). +- **Target review-by date:** to be set at triage; suggested within 30 days of + this note so the OPEN status cannot persist indefinitely. +- **Tracking:** open/link a GitHub issue (label `governance`) for the + determination and reference it here, along with any external assessment. + +Update this file with the owner, date, tracking reference, and the final +determination once made; do not mark the feature "classified" until that +decision is recorded here. Human clinical and regulatory review remains +required, and the feature stays OPEN until the decision is documented. diff --git a/src/components/clinical-dashboard/clinical-dashboard-helpers.ts b/src/components/clinical-dashboard/clinical-dashboard-helpers.ts index 975eace5b..64bcd6b18 100644 --- a/src/components/clinical-dashboard/clinical-dashboard-helpers.ts +++ b/src/components/clinical-dashboard/clinical-dashboard-helpers.ts @@ -72,12 +72,18 @@ export function answerTimedOutError() { export function answerReferencesDocument(answer: RagAnswer | null, documentId: string) { if (!answer) return false; + // Detection must cover every field applyRenamedDocumentToAnswer rewrites + // (incl. quoteCards and the nested smartPanel), otherwise a document referenced + // only there is guarded out and keeps its stale title after a rename. return ( answer.citations.some((citation) => citation.document_id === documentId) || answer.sources.some((source) => source.document_id === documentId) || + Boolean(answer.quoteCards?.some((card) => card.document_id === documentId)) || Boolean(answer.bestSource?.document_id === documentId) || Boolean(answer.relatedDocuments?.some((document) => document.document_id === documentId)) || - Boolean(answer.visualEvidence?.some((image) => image.document_id === documentId)) + Boolean(answer.visualEvidence?.some((image) => image.document_id === documentId)) || + Boolean(answer.smartPanel?.bestSource?.document_id === documentId) || + Boolean(answer.smartPanel?.relatedDocuments?.some((document) => document.document_id === documentId)) ); } diff --git a/tests/clinical-dashboard-helpers.test.ts b/tests/clinical-dashboard-helpers.test.ts index 4ae66d341..bbe7330fa 100644 --- a/tests/clinical-dashboard-helpers.test.ts +++ b/tests/clinical-dashboard-helpers.test.ts @@ -159,12 +159,28 @@ describe("answerReferencesDocument / applyRenamedDocumentToAnswer", () => { } as unknown as RagAnswer; } - it("detects references across citations and sources, and no-ops on null", () => { + it("detects references across every renamed field (citations, sources, quoteCards, smartPanel), and no-ops on null", () => { expect(answerReferencesDocument(null, "d1")).toBe(false); expect(answerReferencesDocument(answer({ citations: [{ document_id: "d1", title: "T" }] as never }), "d1")).toBe( true, ); expect(answerReferencesDocument(answer({ sources: [{ document_id: "d2" }] as never }), "d2")).toBe(true); + // Fields only the rewriter used to touch — detection must cover them too. + expect(answerReferencesDocument(answer({ quoteCards: [{ document_id: "d3", title: "T" }] as never }), "d3")).toBe( + true, + ); + expect( + answerReferencesDocument( + answer({ smartPanel: { bestSource: { document_id: "d4", title: "T" } } as never }), + "d4", + ), + ).toBe(true); + expect( + answerReferencesDocument( + answer({ smartPanel: { relatedDocuments: [{ document_id: "d5", title: "T" }] } as never }), + "d5", + ), + ).toBe(true); expect(answerReferencesDocument(answer({}), "d9")).toBe(false); }); @@ -180,4 +196,21 @@ describe("answerReferencesDocument / applyRenamedDocumentToAnswer", () => { const unrelated = applyRenamedDocumentToAnswer(original, doc({ id: "dX", title: "Nope" })); expect(unrelated).toBe(original); // unchanged reference when not referenced }); + + it("renames a document referenced only through quoteCards or smartPanel", () => { + const quoteOnly = answer({ quoteCards: [{ document_id: "d1", title: "Old" }] as never }); + const renamedQuote = applyRenamedDocumentToAnswer(quoteOnly, doc({ id: "d1", title: "New Title" })); + expect(renamedQuote).not.toBe(quoteOnly); // guard no longer skips quoteCards-only refs + expect(renamedQuote?.quoteCards?.[0].title).toBe("New Title"); + + const smartOnly = answer({ + smartPanel: { + bestSource: { document_id: "d1", title: "Old" }, + relatedDocuments: [{ document_id: "d1", title: "Old" }], + } as never, + }); + const renamedSmart = applyRenamedDocumentToAnswer(smartOnly, doc({ id: "d1", title: "New Title" })); + expect(renamedSmart?.smartPanel?.bestSource?.title).toBe("New Title"); + expect(renamedSmart?.smartPanel?.relatedDocuments?.[0].title).toBe("New Title"); + }); });