import { createContext, useCallback, useContext, useState, ReactNode } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService, HttpError } from './HttpService.tsx'; import type { DraftNote } from './scribe/renderDraftNote'; /** * S6 - the client half of the encounter ambient scribe. * * Consent, and the generated draft. Recording capture itself is `scribe/captureGate.ts`; rendering a * draft into note text is `scribe/renderDraftNote.ts`. This file only talks to the server. * * `environment.apiUrl`, NOT `fhirBaseApi` - every /R4/scribe/* route is an api-service route, not FHIR * resource CRUD, and the wrong base is a silent 404. `apiUrl` * already ends in /R4; do not append another. * * IDS RIDE IN THE QUERY STRING, NEVER THE BODY, on every one of these calls. A Lambda request * authorizer never receives the request body, so a body-borne patient id could not have been authorised * before the handler ran - the same reason ImagingUploadService.tsx does it this way. */ const API_URL = environment.apiUrl; /** What became of the named recording's draft. Only `unconfirmed` leaves cleanup owed. */ export type DraftResolution = 'removed' | 'already-absent' | 'superseded' | 'unconfirmed'; const SETTLED_RESOLUTIONS: readonly DraftResolution[] = ['removed', 'already-absent', 'superseded']; export type ScribeConsentScope = 'encounter' | 'standing' | 'none'; export type ScribeConsentCaptureMethod = 'self-service' | 'staff-attested'; export interface ScribeConsentStatus { permitted: boolean; /** * `'none'` is its own state and must not be collapsed into `permitted: false`. "Nobody has asked * this patient" and "this patient said no" call for different words on screen and a different action * from the practitioner. */ governedBy: ScribeConsentScope; consentId?: string; decidedAt?: string; captureMethod?: ScribeConsentCaptureMethod; } /** * Distinguishes "we could not read the consent record" from "there is no consent". The server returns * 503/CONSENT_UNVERIFIABLE for the first, and the difference has to survive the client too: told the * wrong one, a practitioner goes and re-asks a patient who already agreed. */ export class ScribeConsentUnverifiableError extends Error { constructor() { super('The recording-consent record could not be read right now. Nothing about it has changed.'); this.name = 'ScribeConsentUnverifiableError'; } } /** * Why the server would not mint an upload policy. * * The split that matters is **retryable vs decided**: * - `consent-not-recorded` - 403. Usually a real refusal, but ALSO what a grant looks like for the * first few seconds while HealthLake's search index catches up, so it is retried before it is * believed. * - `consent-unverifiable` - 503. Explicitly transient by the server's own design; retry. * - `ceiling-reached` - 429. A DECISION about spend, not a fault, and the practitioner needs to be * told which it is. * - `failed` - anything else, including a patient-boundary 403 and a transport failure. Deliberately * NOT retried: we do not know it is transient, and guessing would either stall a practitioner for * 30 seconds or make a permanent refusal look flaky. */ export type ScribePresignRefusal = | 'consent-not-recorded' | 'consent-unverifiable' | 'ceiling-reached' | 'failed'; export type ScribePresignResult = | { ok: true; post: { url: string; fields: Record }; jobId?: string } | { ok: false; reason: ScribePresignRefusal }; /** Server `code` the reason above. Anything unlisted is `failed`, which is the safe default. */ const PRESIGN_REFUSALS: Record = { CONSENT_NOT_RECORDED: 'consent-not-recorded', CONSENT_UNVERIFIABLE: 'consent-unverifiable', CEILING_REACHED: 'ceiling-reached', }; export interface RecordConsentParams { /** Required only when STAFF record an in-office decision; ignored on the patient's own door. */ patientId?: string; /** Omit for a standing decision that covers every visit at this practice. */ encounterId?: string; decision: 'permit' | 'deny'; } /** One row of the pending-draft queue. Deliberately carries nothing clinical - see `listDrafts`. */ /** What became of a draft's note: generated, refused by the model, never asked for, or asked for with no answer known. */ export type ScribeNoteOutcome = 'generated' | 'failed' | 'not-sent' | 'unconfirmed'; export interface ScribeDraftSummary { encounterId: string; patientId: string; createdAtMs: number; expiresAtMs: number; noteGenerationFailed: boolean; /** Absent on drafts written before outcomes were recorded. */ noteOutcome?: ScribeNoteOutcome; } export interface ScribeDraft { encounterId: string; patientId: string; practitionerId: string; jobId: string; note: DraftNote | null; /** The transcript arrived but the note could not be generated - say so rather than showing nothing. */ noteGenerationFailed: boolean; /** Why there is no note, where it is known; absent on drafts written before outcomes were recorded. */ noteOutcome?: ScribeNoteOutcome; /** The recording was refused before transcription, so there is neither transcript nor note. */ refusedReason?: 'session-over-cap' | 'recording-unreadable' | 'ceiling-reached'; /** * THERE IS NO `index` FIELD, and there was never meant to be. A citation is a POSITION in this * array, not a value carried on the segment - so a segment's index is its array index and nothing * else. An earlier version of this type declared `index: number`; the S7 smoke read it back off a * real draft as `undefined`, which is what a type describing a field the server does not send looks * like at runtime. Nothing consumed it, so it never broke - it would simply have misled whoever * rendered the transcript next. * * `startSeconds` is nullable because Transcribe emits punctuation items with no timestamps; the * parser reports that as `null` rather than inventing a `0` `scribeTranscript.ts`. */ segments: { speaker: string; text: string; startSeconds: number | null; endSeconds: number | null }[]; createdAtMs: number; expiresAtMs: number; } export const useScribeService = () => { const { get, post, delete: del } = useHttpService(); /* * THE DRAFT-ARRIVED SIGNAL, and it lives HERE because this is where `listDrafts` lives * * Measured by claude-4 on mount `8fb5e793590fa0a39316`: *Finish & transcribe* at 20:05:31, the * draft panel populated on its own at ~20:06:03, and the **"Waiting on you" inches away on * the SAME screen still read `1` at 20:10:09** - four minutes later. A route change ( not a reload) * made it `2` instantly. **The data was available the whole time; only the trigger was missing.** * * WHY A SIGNAL RATHER THAN A FASTER POLL. `useOwedWork` refetches on a **ten-minute** interval, and * this page already issues **two identical `/scribe/drafts` requests** per interval (the retired * toast's hook still polls one of them). Halving the interval doubles a duplicated request and STILL * leaves a window. One notification, at the moment the fact becomes true, closes it exactly. * * WHY ON THE SERVICE rather than a prop or a module global: the producer (`ScribeDraftPanel`, deep * inside the note editor) and the consumer (`useOwedWork`, on the page) are four levels apart with no * shared context that means *"scribe"* - except this one. **An invalidation belongs next to the fetch * it invalidates**, and both ends already hold this service. * * A COUNTER, NOT A TIMESTAMP. Two drafts landing in the same millisecond would produce the same * `Date.now()` and the second would be indistinguishable from no signal at all. */ const [draftsChangedCount, setDraftsChangedCount] = useState(0); const notifyDraftsChanged = useCallback(() => setDraftsChangedCount((count) => count + 1), []); const getConsent = useCallback(async ( patientId: string, encounterId?: string, organizationId?: string, ): Promise => { try { return await get( `${API_URL}/scribe/consent?patient=${encodeURIComponent(patientId)}` + (encounterId ? `&encounter=${encodeURIComponent(encounterId)}` : ``), organizationId ? { organizationId } : undefined, ); } catch (error) { if (error instanceof HttpError && error.status === 503) { throw new ScribeConsentUnverifiableError(); } throw error; } }, [get]); const recordConsent = useCallback(async (params: RecordConsentParams, organizationId?: string): Promise<{ id: string }> => { // The body here is the DECISION, not an identity claim: the patient is derived from the token on // the self-service door, and cross-checked against the caller's care relationship on the staff // door. See recordConsent.ts. return post<{ id: string }>(`${API_URL}/scribe/consent`, params, organizationId ? { organizationId } : undefined); }, [post]); /** * Mint an upload policy for one recording. * * THE REFUSAL REASON IS CARRIED OUT, NOT COLLAPSED. This used to return `null` for every failure, * which made four very different server answers indistinguishable to the client - *"the patient has * not consented"*, *"we could not read the consent record"*, *"this seat is at its monthly ceiling"* * and *"the network failed"* all rendered as **"the recording could not be uploaded"**. * * That mattered in both directions. Two of those answers are **transient and worth retrying** - the * consent index lags a write by ~1-13s (measured), and `CONSENT_UNVERIFIABLE` is a 503 precisely * because retrying is the right response to it. The other two are **decisions**, and presenting a * decision as a fault is the shape: a * clinician told "upload failed" when they have actually hit a spend ceiling goes and fixes the * wrong thing, and one told it after a patient withdrew is left thinking our software broke. * * Mapped on the server's `code`, never on the status alone. Both the patient-boundary denial and a * consent refusal are 403, and only one of them is about consent - keying on the status would tell a * practitioner a patient declined when the truth is they may not open that chart at all. */ const presignRecording = useCallback(async ( patientId: string, encounterId: string, /** Resending one recording: the job its first send was given, so it lands on the same key. */ jobId?: string, ): Promise => { try { const response = await post<{ post: { url: string; fields: Record }; jobId?: string }>( `${API_URL}/scribe/recording?patient=${encodeURIComponent(patientId)}` + `&encounter=${encodeURIComponent(encounterId)}` + (jobId ? `&job=${encodeURIComponent(jobId)}` : ''), {}, ); return response.post ? { ok: true, post: response.post, jobId: response.jobId } : { ok: false, reason: 'failed' }; } catch (error) { const code = error instanceof HttpError ? (error.body as { code?: string } | undefined)?.code : undefined; return { ok: false, reason: PRESIGN_REFUSALS[code ?? ''] ?? 'failed' }; } }, [post]); /** * The caller's OWN outstanding drafts - the queue the expiry nag counts down. * * METADATA ONLY by design: no transcript, no note text. The list is self-scoped and runs no * per-patient boundary check, which is only safe because it carries nothing clinical. The text comes * from `getDraft`, which binds that boundary. */ const listDrafts = useCallback(async (): Promise => { const response = await get<{ drafts: ScribeDraftSummary[] }>(`${API_URL}/scribe/drafts`); return response.drafts ?? []; }, [get]); /** The generated draft for this encounter, or `null` when there is none. */ const getDraft = useCallback(async ( patientId: string, encounterId: string, ): Promise => { try { return await get( `${API_URL}/scribe/draft?patient=${encodeURIComponent(patientId)}` + `&encounter=${encodeURIComponent(encounterId)}`, ); } catch (error) { // 404 is the ordinary "no draft for this encounter" case - the overwhelming majority of // encounters. Surfaced as null rather than thrown so every caller does not have to unwrap it. if (error instanceof HttpError && error.status === 404) return null; throw error; } }, [get]); /** * That recording's draft is finished with - accepted into the record, or discarded. Named by its job, so a * newer recording's draft at the same visit is never removed in its place. * * CALL THIS ONLY AFTER THE NOTE IS COMMITTED. The audio is already deleted by the time a draft * exists, so there is nothing to regenerate from: resolving it while the practitioner is still * editing destroys the only copy of the generated note, and the in-memory editor draft holding it is * discarded by a browser refresh BY DESIGN (EncounterNotesContext.tsx). */ const resolveDraft = useCallback(async ( patientId: string, encounterId: string, jobId: string, outcome: 'accepted' | 'discarded', ): Promise => { let answer: { result?: unknown } | undefined; try { answer = await del<{ result?: unknown }>( `${API_URL}/scribe/draft?patient=${encodeURIComponent(patientId)}` + `&encounter=${encodeURIComponent(encounterId)}&job=${encodeURIComponent(jobId)}&outcome=${outcome}`, ); } catch (error) { // A refusal is the caller's to report. A lost or unconfirmed answer may still have landed, so it stays owed. if (error instanceof HttpError && error.status < 500) throw error; return 'unconfirmed'; } const result = answer?.result as DraftResolution; return SETTLED_RESOLUTIONS.includes(result) ? result : 'unconfirmed'; }, [del]); return { getConsent, recordConsent, presignRecording, listDrafts, getDraft, resolveDraft, /** Bumped each time a draft is OBSERVED to have landed. Consumers treat a change as "re-ask". */ draftsChangedCount, /** Call when a draft has just arrived, so anything counting drafts stops being stale. */ notifyDraftsChanged, }; }; const ScribeServiceContext = createContext | undefined>(undefined); export const ScribeServiceProvider = ({ children }: { children: ReactNode }) => ( {children} ); export const useScribeServiceContext = () => { const context = useContext(ScribeServiceContext); if (!context) { throw new Error('useScribeServiceContext must be used within a ScribeServiceProvider'); } return context; }; /** * The same service, or `undefined` when the provider is not mounted. * * FOR CORE SURFACES THAT MERELY OFFER THE SCRIBE, NEVER FOR THE SCRIBE'S OWN UI. `ClinicalNoteEditor` * is the primary place a clinician writes a note; the scribe is a paid ADD-ON layered on top of it. If * the editor called the throwing accessor, a missing provider anywhere in the tree would take down note * writing entirely - a core clinical surface broken by an optional feature. * * It logs, once, rather than failing silently. A missing provider means the scribe is inert, and * "inert and quiet" is the exact shape this card exists to delete; the difference here is that the harm * of the alternative is worse, so the absence is made noisy instead of fatal. */ let warnedAboutMissingProvider = false; /** Whether the scribe add-on is mounted here, asked without the warning `useOptionalScribeService` gives. */ export const useScribeServiceAvailable = (): boolean => useContext(ScribeServiceContext) !== undefined; export const useOptionalScribeService = () => { const context = useContext(ScribeServiceContext); if (!context && !warnedAboutMissingProvider) { warnedAboutMissingProvider = true; console.error( 'ScribeServiceProvider is not mounted — the encounter scribe is inert on this surface. ' + 'Note writing still works; the draft panel will never appear.', ); } return context; };