Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/samd-classification-medication-considerations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# 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

- **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.
154 changes: 14 additions & 140 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -196,7 +209,6 @@ import type {
QuoteCard,
RagAnswer,
AnswerSection,
RelatedDocument,
SearchResult,
SearchScopeSummary,
ClinicalQueryMode,
Expand All @@ -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,
Expand Down Expand Up @@ -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";
Expand All @@ -313,26 +299,6 @@ type SearchResultModePayload =

type SourceLibrarySearchMode = Extract<AppModeSearchKind, "documents" | "differentials">;

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.
Expand All @@ -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.
*
Expand Down Expand Up @@ -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 = <T extends { document_id: string; title: string }>(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.
*
Expand Down
Loading