AI Scribe suggestion workflow
End-to-end path a recorded transcript takes from the practitioner's microphone to a row in the patient's chart.
Stages
Deepgram live captions
│
▼
TranscriptionSession.stop_session ──► AudioUploadService.uploadAudio
│ (R2 bucket, signed_id back to stop)
▼
TranscriptionProcessingJob ──► LLM call with PromptBuilderService
│ (9 suggestion types, FHIR-aligned)
▼
Clinical::AiSuggestion rows ──► linked to the session + encounter
│
▼
Practitioner reviews in the AI Scribe panel
│
├── reject ──► Clinical::AiSuggestion#reject! (status: rejected)
│
└── accept ──► Clinical::Ai::SuggestionAcceptanceService
│
├── Clinical::AiSuggestion#accept! (status: accepted)
└── Clinical::<ResourceType>.create! + Clinical::AiSuggestionLink.create_for_resource!Service spec table
Clinical::Ai::SuggestionAcceptanceService::RESOURCE_SPECS is the single
declarative source of truth for how each suggestion type becomes a
clinical row. One entry per actionable suggestion type:
'diagnosis' => { model: 'Clinical::Condition', fixed: {…}, fields: {…}, … }
'medication' => { model: 'Clinical::MedicationRequest', fixed: {…}, fields: {…}, … }
'order' => { model: 'Clinical::ServiceRequest', fixed: {…}, fields: {…}, … }
'observation' => { model: 'Clinical::Observation', fixed: {…}, fields: {…}, … }
'care_plan' => { model: 'Clinical::CarePlan', fixed: {…}, fields: {…}, … }
'goal' => { model: 'Clinical::Goal', fixed: {…}, fields: {…}, … }
'allergy' => { model: 'Clinical::AllergyIntolerance',fixed: {…}, fields: {…}, … }
'procedure' => { model: 'Clinical::ServiceRequest', fixed: {…, category: 'procedure'}, … }
'follow_up' => { model: 'Clinical::Observation', fixed: {…, category: 'followup'}, … }The fixed hash is written into every accepted row regardless of the
LLM's output; the fields hash maps from the suggestion's
suggested_resource JSON to model attributes with per-field fallbacks.
A nil default is a plain passthrough.
Race conditions the design guards against
| Race | Guard |
|---|---|
| Two concurrent accepts of the same suggestion | with_lock + re-check of actionable? inside the lock |
| Two clinicians racing on the same suggestion | Same |
| Encounter switch mid-accept | Frontend stillBound check on every post-await store write |
| Suggestion expires while panel is open | actionable? re-evaluated server-side on accept |
| Accept lands while stop is mid-upload | Backend stop_session finalises the session; accept runs against the same session id regardless |
Expiry
expires_at is set at suggestion creation time from
Identity::FacilityAiSettings#suggestion_expiry_hours (default
24 hours). The frontend treats expires_at < now as
non-actionable client-side; the backend enforces the same check inside
the transaction.
Polymorphic link table
clinical.ai_suggestion_links is the join between an accepted suggestion
and the clinical resource it created. One row per accepted suggestion,
with resource_type + a composite (tenant_id, <resource>_id) FK to the
matching clinical table. The seven resource FKs all cascade on parent
delete — see cascade-fk-migrations.md for why and how.
Frontend mirror
frontend/apps/cms/src/features/encounter/utils/aiSuggestionMirror.ts
mirrors RESOURCE_SPECS field-for-field on the frontend, so the
encounter-save delete_all-and-rebuild does not silently delete a row
the backend already created on accept. The contract is: change one side,
change both. See frontend-backend-data-mirroring.md.