Kulcare Docs
General

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

RaceGuard
Two concurrent accepts of the same suggestionwith_lock + re-check of actionable? inside the lock
Two clinicians racing on the same suggestionSame
Encounter switch mid-acceptFrontend stillBound check on every post-await store write
Suggestion expires while panel is openactionable? re-evaluated server-side on accept
Accept lands while stop is mid-uploadBackend 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.

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.

On this page