PsychScribe Local
A local-first clinical-scribe concept for a solo psychiatrist — capture sessions, produce clinician-reviewed notes, strictly offline by default.
The Problem
Clinical documentation eats a private-practice psychiatrist’s day, and the cloud “AI scribe” tools that promise relief route patient audio and notes through someone else’s servers. For a solo clinician handling sensitive psychiatric sessions, strict local/offline operation isn’t a nice-to-have — it’s the precondition for using the tool at all.
What I Built
PsychScribe Local is a macOS desktop concept for capturing clinical sessions and producing clinician-reviewed notes, with strict offline operation as the default. The shared architecture is a local-first clinical scribe for physicians and therapists; the first specialty pack is psychiatry, optimized for medication-management and therapy visits.
The repo is further along than a spec scaffold on the app-slice track: PsychScribeCore is a real Swift package whose test suite passes against real GRDB 6.29.3 and WhisperKit 0.18.0, and a SwiftUI macOS app builds and runs an Import → Transcribe → Review → Draft → Export slice end to end. Live chunked microphone capture is a compiling stub; file import is the working capture path for v0. Transcript clinical accuracy is explicitly unvalidated until a large-v3 benchmark run on target M1 hardware — the review gate described below is the safety boundary until then.
Architecture
One imported audio file traces through seven stages, all local:
- Import —
AudioImporterreads the source file; live mic capture goes throughSessionRecorder/ChunkCryptobut recording itself is a stub in v0, so import is the working entry point. - Chunked transcription —
ChunkedTranscriptionPipelinereads the file in fixed 30-second windows with a 3-second trailing overlap (mono 16 kHz Float32) rather than decoding the whole file into RAM, because a single longtranscribeFilecall was measured at ~3.8 MB/min of decoded audio with no bound for multi-hour sessions. Each window is transcribed in isolation byWhisperKitTranscriber, so only one window plus one temp file exist in memory/disk at a time regardless of total session length. - Stitching —
TranscriptStitcherreconciles the overlapping regions between adjacent windows by timestamp proximity and text similarity into oneTranscriptDocumentin a provisional state. - Transcript review — the clinician edits segment text and marks the transcript approved in the Transcript Review UI.
ReviewGateEngine.canExportblocks everything downstream — note generation and export — untiltranscript.state == .approved. - Note drafting — once approved,
OllamaNoteGenerator.draftNotesends the transcript and aNoteTemplateto a local Ollama server (localhost:11434, OpenAI-compatible/v1/chat/completions, falling back to/api/chat) runningllama3.2:3b, with a system prompt that forbids stating anything not present in the transcript and requires[clinician to confirm]placeholders for anything the model can’t source. - Deterministic safety flagging —
ClinicalFactFlagger, a regex/lexicon scanner with no LLM involved, runs twice: once over the raw transcript, once over the LLM-drafted note text. It flags every medication name, dose/frequency pattern, diagnosis cue, risk/SI/HI/self-harm phrase, safety-plan mention, and level-of-care phrase as a pendingReviewGate. This is the actual export gate:ReviewGateEngineblocks Markdown/TXT export until every gate is resolved (a dev-only override exists, labeled as such in the UI) — the LLM drafts, the deterministic scanner is what enforces the AGENTS.md hard rule that clinical content requires human review before it leaves the app. - Export —
MarkdownExporter(v0) orTranscriptTextExporterwrites anExportArtifactto local disk; no network call is made at this stage. PDF export is planned, not yet implemented.
Audio is encrypted at rest throughout via ChunkCrypto (AES-GCM/CryptoKit, symmetric key in the macOS Keychain, custom authenticated file format PSENC002), decrypted only transiently in memory for transcription. Structured records (Project, Encounter, TranscriptDocument, ClinicalNote) persist through GRDBClinicalStore — GRDB 6 over plain SQLite, one JSON-blob column per row keyed by id (encounter-indexed for transcripts/notes). SQLCipher (DB-at-rest encryption) is deferred to a named follow-up hardening step (ADR 0005); v0 accepts plain SQLite because only synthetic, non-PHI fixtures are used — the higher-sensitivity artifact, audio, is already encrypted independently.
A separate track, tools/asr-bench, benchmarks WhisperKit models against synthetic non-PHI fixtures (WER, medication-term recall, real-time factor) to decide which model is clinically viable for the accuracy pass — the first real benchmark run, openai_whisper-tiny over 6 fixtures, measured 52.8% avg WER and 48.1% avg term recall, missing most medication names and dose values, which is why the app default stays tiny for dev/demo and large-v3 is the named target for a real accuracy pass, not yet run on target hardware.
flowchart LR
A["Imported audio file<br/>(mic capture is a stub)"] --> B["ChunkCrypto<br/>AES-GCM, Keychain key"]
B --> C["ChunkedTranscriptionPipeline<br/>30s windows, 3s overlap"]
C --> D["WhisperKitTranscriber<br/>tiny default -> base/small/large-v3 toggle"]
D --> E["TranscriptStitcher<br/>overlap reconciliation"]
E --> F[("GRDBClinicalStore<br/>SQLite, JSON blobs")]
E --> G["Transcript Review UI<br/>clinician edits + approves"]
G --> H{"ReviewGateEngine<br/>blocks until approved"}
H --> I["OllamaNoteGenerator<br/>llama3.2:3b @ localhost:11434"]
I --> J["ClinicalFactFlagger<br/>deterministic regex/lexicon scan<br/>meds, doses, dx, SI/HI, safety-plan"]
J --> K["Note Review UI<br/>resolve pending gates"]
K --> L{"ReviewGateEngine<br/>blocks export until gates resolved"}
L --> M["MarkdownExporter / TranscriptTextExporter<br/>local disk, no network"]
N["tools/asr-bench<br/>WER / term-recall / RTF harness"] -.picks model for.-> D
Models — which, where, why:
| Stage | Model / tier | Where it runs | Why there |
|---|---|---|---|
| Transcription | WhisperKit; default openai_whisper-tiny, with a UI toggle to base / small / large-v3 | On-device, WhisperKitTranscriber (Argmax WhisperKit 0.18.0) | Native Apple Silicon Whisper port; tiny is the dev/demo default and the only model benchmarked so far — the first real run missed most medication names and doses, confirming large-v3 is required before any clinical use |
| Note drafting + fact extraction | Ollama llama3.2:3b | Local Ollama server, localhost:11434, OpenAI-compatible /v1/chat/completions (falls back to /api/chat) — OllamaNoteGenerator uses URLSession only, no SDK | Keeps note generation off any network; a 3B model fits the M1/16 GB target machine; the system prompt bans stating anything not present in the transcript |
| Clinical safety flagging | Models: none — deterministic regex/lexicon scan | ClinicalFactFlagger, in-process, run over both the transcript and the LLM-drafted note | Medication/dose/diagnosis/risk/SI-HI/safety-plan detection has to be reliable and auditable, not probabilistic — this is the actual export-blocking check, independent of whatever the LLM drafted |
Tools & infra — which, why:
- Storage — GRDB 6 over SQLite for structured records (
Project/Encounter/TranscriptDocument/ClinicalNote, JSON-blob columns); plain SQLite in v0, not SQLCipher, because only synthetic non-PHI fixtures exist during prototyping — DB-at-rest encryption is a tracked next step (ADR 0005). - Audio encryption — CryptoKit AES-GCM with the symmetric key held in the macOS Keychain (
Securityframework), a custom authenticated file format (PSENC002) versioned past an earlierPSENC001; audio is the highest-sensitivity artifact so it’s encrypted independently of the DB-at-rest gap above. - Local LLM runtime — Ollama, reached over plain HTTP at
localhost:11434; no vendor SDK, justURLSessionagainst the OpenAI-compatible chat endpoint. - ASR engine — WhisperKit (Argmax) 0.18.0 as a Swift package dependency; downloads the selected model once, then runs fully offline.
- App shell — native SwiftUI macOS app,
XcodeGen-generated Xcode project (xcodegen generate+xcodebuild) — chosen over the Tauri + React alternative weighed in the tech-stack direction doc, in favor of direct WhisperKit/Keychain/AVFoundation integration. - Benchmark harness —
tools/asr-bench, a standalone Swift executable scoring WhisperKit models against synthetic (non-PHI) fixtures viaWERScorer/TermRecallScorer/BenchMetrics(WER, medication-term recall, real-time factor, peak RSS). - Hosting — none; the app is 100% local with no cloud deploy. The repo’s “Cloud Boundary” policy requires explicit opt-in, a signed BAA, and documented data flow before any cloud ASR/LLM/telemetry vendor can be added.
How it works
- The clinician imports a session audio file (live chunked mic recording exists as a stub, not the working path yet).
ChunkedTranscriptionPipelinereads the file in 30-second windows with a 3-second overlap, transcribing each window throughWhisperKitTranscriber(defaultopenai_whisper-tiny) so memory use stays bounded regardless of session length.TranscriptStitcherreconciles the overlapping regions by timestamp and text similarity into one provisionalTranscriptDocument.- The clinician reviews and edits the transcript in the Transcript Review UI and marks it approved;
ReviewGateEngineblocks every step past this point until that approval is set. OllamaNoteGeneratorsends the approved transcript plus a psychiatry note template to a local Ollama server (llama3.2:3b) and gets back a structuredClinicalNotedraft, with sections that cite transcript spans or are marked[clinician to confirm].ClinicalFactFlaggerdeterministically re-scans both the transcript and the drafted note for medication names, doses/frequencies, diagnosis cues, SI/HI/self-harm/safety-plan language, and level-of-care phrases, producing a pendingReviewGatefor each hit.- The clinician resolves every pending gate in the Note Review UI;
ReviewGateEngine.canExportonly returnsallowed: trueonce the transcript is approved, all gates are resolved, and consent wasn’t declined (a labeled dev-only override exists to unblock prototype work). MarkdownExporterorTranscriptTextExporterwrites the approved note to local disk (Markdown/TXT in v0; PDF planned) — no network call happens at export.- Independently,
tools/asr-benchruns WhisperKit models against synthetic fixtures to measure WER, medication-term recall, and real-time factor, informing which model is promoted to the default before any real clinical use is considered.
Tech stack
SwiftUI macOS app (XcodeGen project) plus a shared PsychScribeCore Swift package consumed by both the app and the asr-bench harness. WhisperKit (Argmax, 0.18.0) for on-device transcription. Ollama (llama3.2:3b, local HTTP, OpenAI-compatible endpoint) for note drafting — no cloud LLM. GRDB 6 over SQLite for structured storage; CryptoKit AES-GCM plus macOS Keychain for audio-at-rest encryption. ClinicalFactFlagger is a from-scratch deterministic regex/lexicon scanner, not a library or a model. No cloud services, no hosting — the app runs entirely on-device.
Locked Defaults
- Platform — macOS desktop first (iPhone/iPad later), sized for an M1 MacBook Pro running back-to-back visits across a full clinical day.
- PHI posture — strict local/offline by default; audio retained encrypted only until the transcript and note are approved, then deleted.
- Clinician in the loop — notes are drafted for review, never auto-filed.
Status and Boundaries
The repo pairs a product/specification scaffold (design docs, ADRs, compliance planning) with a compiling, tested app-slice prototype (see Architecture above). Neither makes it clinical software: it is not HIPAA certified, transcript accuracy is unvalidated on target hardware, and it must not be used with real patient data until the compliance-evidence package and clinical-validation gates are complete. The portfolio entry documents the product direction and the privacy-first architecture, not a shipping medical device.