Compare commits

..

445 commits
v7.3.0 ... main

Author SHA1 Message Date
Daniel
f556d50a09 Remove the --gh flag from release.sh
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m46s
release.sh --gh ran `gh release create` against GitHub. This project
publishes releases to Forgejo, and CI does it automatically on a v* tag
push (.forgejo/workflows/android-apk.yml attaches the signed APK for
Obtainium). The flag could not do anything useful here, and having it in
the usage text implied a manual publish step that does not exist.

Drops the flag, its DO_RELEASE branch, and the usage/header references.
--push remains the only option.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:52:04 +02:00
Daniel
3fb4c10f2b Point the APK download link at Forgejo
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m48s
The login page linked to
github.com/ifedan-ed/pediatric-ai-scribe-v3/releases/latest, which returns
404 — releases are published to git.danvics.com by the Forgejo APK
workflow. Verified: the Forgejo URL returns 200, the GitHub one 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:17:29 +02:00
Daniel
4613a27879 Release v7.14.16
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 3m13s
2026-07-31 01:12:47 +02:00
Daniel
f31afcdbf4 Fix biometric login, and keep recording alive across screen lock
Biometric sign-in has never worked. auth.js drove
window.Capacitor.Plugins.NativeBiometric — the API of
capacitor-native-biometric, which is not a dependency of this project. The
installed plugin is @aparajita/capacitor-biometric-auth, registered as
BiometricAuthNative with an entirely different API and no credential
storage at all. bioPlugin() therefore always returned null, bioAvailable()
always resolved {ok:false}, and the button was never revealed.

Rewritten against what is actually installed, with no new dependency:
BiometricAuthNative (checkBiometry/authenticate) presents the prompt, and
the already-working SecureStoragePlugin — via the window.SecureStorage
wrapper — holds the credentials. Credentials are only read after
authenticate() resolves, so the OS still gates access. biometryType is a
numeric enum in this plugin, so the old FACE_ID/TOUCH_ID string maps are
replaced with a single lookup exposed as typeName.

Secure storage itself was fine and is unchanged: SecureStoragePlugin
matches the name the wrapper looks up and is registered in
capacitor.settings.gradle.

Recording across screen lock: window.nativeKeepAwake() called Capacitor's
KeepAwake plugin, which is also not installed here, so it silently did
nothing and the device slept mid-encounter — taking the WebView's
MediaRecorder with it. keepAwake() is now a method on the existing
NativeRecording JavascriptInterface, which sets FLAG_KEEP_SCREEN_ON, and
is bound to the WebView so it survives the launcher's navigation to the
remote origin. The Capacitor plugin remains a fallback.

If the screen is locked anyway (power button, incoming call), the activity
pauses and Chromium throttles timers for hidden WebViews, starving
MediaRecorder's chunk delivery. MainActivity now calls resumeTimers() on
pause while recording. The foreground service was already correct — it
holds a partial wake lock and declares FOREGROUND_SERVICE_TYPE_MICROPHONE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:12:47 +02:00
Daniel
a814d2a2c2 Fix release.sh pushing to a remote that does not exist
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m27s
--push ran `git push origin HEAD`, but this repo's only remote is named
forgejo — so the flag failed after the script had already committed and
tagged, leaving the release half-done. Resolve the remote instead:
prefer forgejo, fall back to origin, else use the only remote present,
and fail with a clear message if there is none.

Also corrects the header comment, which claimed the script does not build
the APK and pointed at --gh / gh CLI. Forgejo CI builds the APK on every
branch push and attaches it to a Forgejo release on a v* tag push, which
is what Obtainium tracks. --gh is a leftover from the GitHub era.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:05:43 +02:00
Daniel
bca107846e Release v7.14.15
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 4m43s
2026-07-30 17:34:39 +02:00
Daniel
524ad40d49 Match TTS voices to the selected LiteLLM model
Voice lists were a single flat set from LITELLM_TTS_VOICES, so picking a
model could leave an incompatible voice selected and the request would
fail at the gateway. Voices are now resolved per model family (Kokoro,
Kitten, Supertonic, Groq Orpheus EN/AR), with a compatibility check that
falls back through user → admin → env → first valid voice. Groq Orpheus
requests also pin response_format to wav.

Also refreshes the cardiac/respiratory auscultation samples, extends the
well-visit component, and fixes the Android launch theme background
(@null → colorPrimary) so the splash does not flash through.

NOTE: this is in-progress work that was already sitting uncommitted in
the working tree; it is committed here as-is so the tree was clean for
the release bump.
2026-07-30 17:34:34 +02:00
Daniel
82ed46d01b Fix Turnstile in the mobile app; drop it from login
The Turnstile challenge failed reliably inside the Capacitor WebView,
which blocked login and registration from the Android app.

Three separate causes:

1. Android WebView blocks third-party cookies by default. Turnstile runs
   in a cross-origin iframe from challenges.cloudflare.com and needs its
   own storage, so the widget never emitted a token. MainActivity now
   calls setAcceptThirdPartyCookies on the app's own WebView.

2. The register handler read the Turnstile response with an unscoped
   document.querySelector, which matched the *login* widget's input (it
   comes first in the DOM). Registration therefore submitted the login
   widget's token — single-use with a 5 minute expiry, so any prior login
   attempt or slow signup made it fail server-side.

3. The register and forgot-password widgets auto-rendered inside forms
   that start at display:none, where Turnstile does not reliably complete
   a challenge, and nothing re-rendered them when the form was shown.

Widgets are now rendered explicitly when their form first becomes
visible, and tokens are captured from the render callback instead of
being read back out of the injected input — which makes the unscoped
lookup in (2) structurally impossible. Added error/expired/timeout
callbacks so a widget failure surfaces the Cloudflare error code instead
of failing silently behind a generic toast.

Login is no longer gated at all. It is the path mobile users hit
constantly, and it is already covered by a 10-per-15-min per-IP rate
limit, a constant-time credential check, and TOTP 2FA. Registration and
password reset — the endpoints that actually attract bots — stay gated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:22:03 +02:00
Daniel
bee9361c1d Fix authenticated mobile image downloads
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m39s
2026-06-09 16:02:11 +02:00
Daniel
2ca969e099 Fix generated image mobile downloads
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m21s
2026-06-09 15:20:06 +02:00
Daniel
80139d9a82 Prevent mobile image download preview fallback
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
2026-06-09 03:11:35 +02:00
Daniel
f7cfc6695d Fix clinical assistant image downloads
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
2026-06-08 22:15:34 +02:00
Daniel
b0e1f4969a feat: improve clinical assistant prompts
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m15s
2026-06-06 00:01:07 +02:00
Daniel
d02b9e2771 Fix clinical prompt pool fallback seeding
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m14s
2026-05-22 16:53:53 +02:00
Daniel
c7921ab822 Release v7.14.9
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 3m51s
2026-05-22 06:06:43 +02:00
Daniel
fb3e4d4135 Update deployment networks
Some checks are pending
Forgejo Android APK / Build signed APK (push) Waiting to run
2026-05-22 05:59:48 +02:00
Daniel
cb63729656 Expand clinical prompt pool taxonomy
Some checks are pending
Forgejo Android APK / Build signed APK (push) Waiting to run
2026-05-22 05:56:08 +02:00
Daniel
2cef65fb1f Fix mobile assistant image downloads
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
2026-05-13 16:07:41 +02:00
Daniel
629dea808e Fix assistant cancel button visibility
Some checks are pending
Forgejo Android APK / Build signed APK (push) Waiting to run
2026-05-12 16:20:10 +02:00
Daniel
8dca18292a fix assistant cancel hidden state
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
2026-05-11 18:05:03 +02:00
Daniel
1f5e4aabac fix assistant cancel visibility
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
2026-05-11 17:53:54 +02:00
Daniel
97ddd87449 fix mobile assistant table streaming
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
2026-05-11 16:15:45 +02:00
Daniel
e1266c6d38 ci: route Android APK through Forgejo releases
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
2026-05-11 04:21:15 +02:00
Daniel
6e8fae72e7 fix mobile image save to photos
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
2026-05-11 01:28:43 +02:00
Daniel
2c287bd1b3 fix mobile export save actions
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
2026-05-10 23:56:51 +02:00
Daniel
f871384063 fix mobile assistant export and image actions
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
2026-05-10 20:28:46 +02:00
Daniel
977ebfc037 ci: publish forgejo apk releases 2026-05-10 19:37:52 +02:00
Daniel
212ce7dd95 ci: use forgejo-compatible artifact upload 2026-05-10 18:48:40 +02:00
Daniel
b29c6f7717 ci: harden forgejo android signing restore 2026-05-10 18:31:57 +02:00
Daniel
8f51e56723 ci: fetch android setup actions from github 2026-05-10 18:24:12 +02:00
Daniel
7fe0a0e7ec ci: prepare compose env files in forgejo 2026-05-10 18:21:18 +02:00
Daniel
71655aa7e9 ci: use forgejo local runner label 2026-05-10 18:13:14 +02:00
Daniel
f01ca5a094 ci: scope github release workflows 2026-05-10 18:07:52 +02:00
Daniel
52544e9116 ci: add forgejo build workflows 2026-05-10 18:05:55 +02:00
Daniel
046b07a84a fix clinical assistant mobile exports 2026-05-10 17:23:54 +02:00
Daniel
cddc1a4d79 document architecture and harden rendering 2026-05-10 01:07:56 +02:00
Daniel
a176e1b014 protect code blocks during citation repair 2026-05-09 20:54:05 +02:00
Daniel
83d9a77160 fix table source citation links 2026-05-09 20:50:54 +02:00
Daniel
baf0020981 prefer file names for clinical sources 2026-05-09 20:10:24 +02:00
Daniel
795ad9ffae harden clinical assistant source handling 2026-05-09 19:59:04 +02:00
Daniel
8d69fe57a5 fix litellm metadata discovery auth 2026-05-09 15:15:47 +02:00
Daniel
90cdf17bd9 fix litellm capability discovery 2026-05-09 15:06:28 +02:00
Daniel
1b3ea569b7 simplify speech and embeddings through litellm 2026-05-09 05:09:02 +02:00
Daniel
79037fa775 fix litellm tts search fallbacks 2026-05-09 04:50:55 +02:00
Daniel
2a3631d067 fix litellm metadata model discovery 2026-05-09 04:46:06 +02:00
Daniel
ea12b9a46f chore add pull request template 2026-05-09 04:12:57 +02:00
Daniel
2387e6f136 fix litellm speech model discovery 2026-05-09 04:12:57 +02:00
Daniel
39d77116ac docs remove stale extended guide 2026-05-09 04:08:27 +02:00
Daniel
6f5782734f fix stt local model defaults 2026-05-09 01:57:37 +02:00
Daniel
bb31c6f515 test docs toc links 2026-05-09 01:33:06 +02:00
Daniel
113f004230 docs refresh current workflows 2026-05-09 00:40:45 +02:00
Daniel
05f8b00401 docs align memory and audio backup behavior 2026-05-08 23:32:39 +02:00
Daniel
0503a25d0b fix docs toc fallback and remove redundant iifes 2026-05-08 23:08:47 +02:00
Daniel
e84f19b5cb fix docs reader anchor navigation 2026-05-08 22:56:03 +02:00
Daniel
493a1d230c fix docs table of contents scrolling 2026-05-08 22:41:47 +02:00
github-actions[bot]
d108bdc091 Release v7.14.0
Some checks failed
Build & release Android APK / Build signed APK (push) Has been cancelled
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / Build linux/amd64 (push) Has been cancelled
Build & Push Docker Image / Build linux/arm64 (push) Has been cancelled
Build & Push Docker Image / Merge manifests (push) Has been cancelled
2026-05-08 20:27:26 +00:00
Daniel
416fff624a feat: add patient education handouts 2026-05-08 22:26:53 +02:00
Daniel
1cbe248450 feat: add extension import preview 2026-05-08 22:26:53 +02:00
github-actions[bot]
b7f9da6600 Release v7.13.0
Some checks failed
Build & release Android APK / Build signed APK (push) Has been cancelled
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / Build linux/amd64 (push) Has been cancelled
Build & Push Docker Image / Build linux/arm64 (push) Has been cancelled
Build & Push Docker Image / Merge manifests (push) Has been cancelled
2026-05-08 18:49:16 +00:00
Daniel
4a9a518134 add extension transfer workflow 2026-05-08 20:48:58 +02:00
Daniel
ba180d7dde fix metrics route normalization 2026-05-08 20:22:14 +02:00
github-actions[bot]
446a3b33d0 Release v7.12.0 2026-05-08 17:20:31 +00:00
Daniel
aeb31a2d15 scrub vendor model docs 2026-05-08 19:15:54 +02:00
Daniel
210ec06fe5 remove vendor model references 2026-05-08 19:14:51 +02:00
Daniel
a6807ef7a4 add hardening and metrics tests 2026-05-08 19:08:29 +02:00
Daniel
a08524d95f harden logging and observability 2026-05-08 19:08:19 +02:00
Daniel
467593a109 extract learning hub ai panel controller 2026-05-08 16:54:48 +02:00
Daniel
92351e1ab5 extract learning hub webdav controller 2026-05-08 16:44:02 +02:00
Daniel
dc62cc880d extract learning hub quiz controller 2026-05-08 16:38:51 +02:00
Daniel
d97d92f8f2 extract learning hub cms controller 2026-05-08 16:23:01 +02:00
Daniel
41598e0fb6 extract learning hub slide controller 2026-05-08 16:04:21 +02:00
Daniel
7858484cb0 refactor learning hub renderers 2026-05-08 15:47:10 +02:00
Daniel
6d13765fc4 improve learning hub generation flow 2026-05-08 09:32:58 +02:00
Daniel
d8e9ba149e allow bedside respiratory without weight 2026-05-08 09:10:33 +02:00
Daniel
d71482037f destroy learning hub row editors 2026-05-08 09:05:13 +02:00
Daniel
de3e1c0a15 remove sutures disclaimer copy 2026-05-08 09:05:13 +02:00
Daniel
2fec33dc4d convert learning hub to module 2026-05-08 09:01:34 +02:00
Daniel
df87d93306 remove legacy well visit schedule data 2026-05-08 08:59:23 +02:00
Daniel
e731780c7b move pe guide data to json 2026-05-08 08:56:21 +02:00
Daniel
300b40b181 set clinical prompt pool target 2026-05-08 08:52:29 +02:00
Daniel
27c3e07d98 fix learning hub model selector 2026-05-08 08:48:02 +02:00
Daniel
9167532a14 use indexed assistant prompt examples 2026-05-08 08:44:34 +02:00
Daniel
db2ecca45d remove top bar model selector 2026-05-08 08:28:54 +02:00
Daniel
6da5565f89 harden audio backup settings rendering 2026-05-08 08:23:38 +02:00
Daniel
04e736eb2e convert speech helpers to modules 2026-05-08 08:09:38 +02:00
Daniel
ea52890908 gate browser speech recognition setting 2026-05-08 08:05:23 +02:00
Daniel
9ca5365daf convert auth helpers to modules 2026-05-08 07:56:41 +02:00
Daniel
bc0b43151d convert settings helpers to modules 2026-05-08 07:54:08 +02:00
Daniel
b6ebca0e6f convert admin docs and drug loader to modules 2026-05-08 07:50:26 +02:00
Daniel
1756043125 test note refine correction policy 2026-05-08 07:46:39 +02:00
Daniel
03ee07f92f clarify template-only AI memory context 2026-05-08 07:38:27 +02:00
Daniel
20fc6798a9 remove browser whisper transcription 2026-05-08 07:33:12 +02:00
Daniel
59fa229b59 convert settings loaders to modules 2026-05-08 07:17:29 +02:00
Daniel
38667608b1 convert encounter note entrypoints to modules 2026-05-08 07:15:11 +02:00
Daniel
02f348ddb3 convert clinical note entrypoints to modules 2026-05-08 07:13:08 +02:00
Daniel
ffffe17b30 tighten chart review source coverage 2026-05-08 07:10:00 +02:00
Daniel
d48a19a891 honor admin default model selections 2026-05-08 06:49:50 +02:00
Daniel
c458c4b4ff convert chart review entrypoint to module 2026-05-08 06:49:46 +02:00
Daniel
e05720083a test clinical note entrypoints 2026-05-08 06:33:34 +02:00
Daniel
90938f8ec1 fix diagram delete confirmation 2026-05-08 06:28:12 +02:00
Daniel
289b0197e7 remove redundant app wrappers 2026-05-08 06:21:55 +02:00
Daniel
0102c9cbc6 convert admin entrypoint to module 2026-05-08 06:19:51 +02:00
Daniel
3be21a137b remove unused admin provider imports 2026-05-08 06:13:11 +02:00
Daniel
548c39a883 normalize LiteLLM embedding requests 2026-05-08 06:11:36 +02:00
Daniel
b40941e4d5 centralize LiteLLM admin headers 2026-05-08 06:08:46 +02:00
Daniel
48ee92fc5d extract STT provider handling 2026-05-08 06:05:39 +02:00
Daniel
d4a3c8fd60 simplify TTS provider handling 2026-05-08 06:00:33 +02:00
Daniel
ca9be8bd85 remove redundant module wrappers 2026-05-08 05:30:32 +02:00
Daniel
34f198edc0 extract well visit schedule data 2026-05-08 05:11:29 +02:00
Daniel
c52f7664b9 deduplicate admin HTML escaping 2026-05-08 04:45:02 +02:00
Daniel
784d1a2e21 consolidate calculator growth helpers 2026-05-08 04:36:39 +02:00
Daniel
9b5f01a19b extract calculator GCS helper 2026-05-08 04:33:46 +02:00
Daniel
ae0ca83ccb extract calculator dosing helpers 2026-05-08 04:32:09 +02:00
Daniel
c2eb550048 ignore vendor model local settings 2026-05-08 04:27:17 +02:00
Daniel
41a05a6b5e extract calculator BP data 2026-05-08 04:24:18 +02:00
Daniel
b75be521ab extract calculator growth data 2026-05-08 04:05:51 +02:00
Daniel
951d102ac0 add BMI calculator combination tests 2026-05-08 03:57:30 +02:00
Daniel
bbd03bfeed avoid hardcoded BMI LMS anchors 2026-05-08 03:56:28 +02:00
Daniel
d79e578863 extract calculator BMI data 2026-05-08 03:52:08 +02:00
Daniel
903cd7eca8 extract AAP bilirubin threshold data 2026-05-08 03:42:51 +02:00
Daniel
d8b8f9bdcb extract bilirubin risk zone data 2026-05-08 03:28:52 +02:00
Daniel
f566455108 remove duplicate bedside handlers from calculators 2026-05-08 03:25:30 +02:00
Daniel
6a081bb53b extract calculator reference data 2026-05-08 03:18:37 +02:00
Daniel
3c4ca84b5f extract calculator vitals data 2026-05-08 03:06:14 +02:00
Daniel
e4ea553bee extract calculator shared modules 2026-05-08 02:58:58 +02:00
Daniel
4a14a71151 split notes frontend modules 2026-05-08 02:44:22 +02:00
Daniel
1ed1a37161 convert notes scripts to modules 2026-05-08 01:37:41 +02:00
Daniel
116bd941e1 extract notes api client 2026-05-08 01:31:06 +02:00
Daniel
326fb726a1 split assistant frontend modules 2026-05-08 01:19:12 +02:00
Daniel
f54b293d39 extract assistant frontend api client 2026-05-08 00:58:44 +02:00
Daniel
7c700ed7f5 refactor clinical assistant backend utilities 2026-05-08 00:55:58 +02:00
Daniel
10f39fc4ff restore assistant streaming 2026-05-08 00:26:10 +02:00
Daniel
a8f364f177 stabilize assistant and add diagrams 2026-05-08 00:23:00 +02:00
Daniel
4f3f5d2f05 fix assistant truncated answer detection 2026-05-07 23:38:35 +02:00
Daniel
49a2bed0d0 fix assistant image follow-up routing 2026-05-07 23:06:13 +02:00
Daniel
569363754f fix assistant stream truncation handling 2026-05-07 23:02:26 +02:00
Daniel
70f6aa4ac6 fix clinical assistant export cleanup 2026-05-07 22:52:46 +02:00
Daniel
c8436d5e4c refactor clinical assistant prompt handling 2026-05-07 22:52:46 +02:00
github-actions[bot]
27ffbfdd77 Release v7.10.1 2026-05-07 15:44:22 +00:00
Daniel
3d7fea8639 fix: simplify assistant lookup wording 2026-05-07 17:44:06 +02:00
github-actions[bot]
d4c85ad638 Release v7.10.0 2026-05-07 15:26:34 +00:00
Daniel
b9270414b0 feat: stream clinical assistant responses 2026-05-07 17:26:10 +02:00
github-actions[bot]
18b219dbff Release v7.9.0 2026-05-07 15:15:48 +00:00
Daniel
21fb631fb5 feat: improve clinical assistant export and citations 2026-05-07 17:15:26 +02:00
github-actions[bot]
5e5c219d33 Release v7.8.0 2026-05-07 02:43:00 +00:00
Daniel
fb339325ee feat: use visual captions in assistant sources 2026-05-07 04:42:32 +02:00
github-actions[bot]
d600c98153 Release v7.7.0 2026-05-07 01:43:57 +00:00
Daniel
198fd8e809 feat: use indexed assistant topic suggestions 2026-05-07 03:43:41 +02:00
github-actions[bot]
566d5c7e8d Release v7.6.1 2026-05-07 01:33:35 +00:00
Daniel
5e926de010 fix: use portrait canvas for assistant flowcharts 2026-05-07 03:33:26 +02:00
github-actions[bot]
f35dd65f2f Release v7.6.0 2026-05-07 01:20:07 +00:00
Daniel
b2f3539c5e feat: diversify assistant starter prompts 2026-05-07 03:19:51 +02:00
github-actions[bot]
ced5d6fc6b Release v7.5.0 2026-05-07 01:01:54 +00:00
Daniel
18109f8bcf feat: map assistant visual source intent 2026-05-07 03:01:45 +02:00
github-actions[bot]
dfdd2c8740 Release v7.4.0 2026-05-07 00:45:32 +00:00
Daniel
f134d0e80c feat: improve clinical assistant visual sources 2026-05-07 02:45:17 +02:00
Daniel
e4a95ad72e refactor: move assistant examples to module 2026-05-06 23:39:41 +02:00
github-actions[bot]
cc50fb8c59 Release v7.3.0 2026-05-06 21:35:12 +00:00
Daniel
098da5d5e3 feat: blend multimodal assistant sources 2026-05-06 23:35:02 +02:00
github-actions[bot]
ed76107a2b Release v7.2.1 2026-05-06 21:32:00 +00:00
Daniel
c39630792b fix: preview generated assistant images inline 2026-05-06 23:31:43 +02:00
github-actions[bot]
6c9ef4cef9 Release v7.2.0 2026-05-06 21:29:30 +00:00
Daniel
605e76b14c feat: add assistant PDF export 2026-05-06 23:29:20 +02:00
github-actions[bot]
d50640ad81 Release v7.1.5 2026-05-06 21:08:07 +00:00
Daniel
6dcfbcd456 fix: enforce Vancouver citation clusters 2026-05-06 23:07:56 +02:00
github-actions[bot]
81884f8a36 Release v7.1.4 2026-05-06 21:04:49 +00:00
Daniel
6c25e8ff05 fix: polish clinical assistant citations 2026-05-06 23:04:40 +02:00
github-actions[bot]
31163e1894 Release v7.1.3 2026-05-06 06:19:42 +00:00
Daniel
96306673a3 fix: show assistant progress and order citations 2026-05-06 08:19:31 +02:00
github-actions[bot]
63fe53a029 Release v7.1.2 2026-05-06 06:04:06 +00:00
Daniel
2310c43aea fix: improve clinical assistant rendering 2026-05-06 08:03:28 +02:00
github-actions[bot]
81c627bec7 Release v7.1.1 2026-05-06 05:42:13 +00:00
Daniel
33ca4e65d9 fix: harden clinical assistant MCP connectivity 2026-05-06 07:42:03 +02:00
Daniel
c8e911cb64 chore: sync package lock 2026-05-06 07:34:10 +02:00
Daniel
96c4565b9c feat: add clinical assistant MCP search
Some checks failed
Build & release Android APK / Build signed APK (push) Has been cancelled
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / Build linux/amd64 (push) Has been cancelled
Build & Push Docker Image / Build linux/arm64 (push) Has been cancelled
Build & Push Docker Image / Merge manifests (push) Has been cancelled
2026-05-06 07:31:32 +02:00
Daniel
0710c4de8e rebrand to Pediatric Clinical Tools, add diagrams tab, simplify litellm transcribe/tts 2026-05-02 18:23:06 +02:00
Daniel
cf1d88f36b Release v7.0.0 2026-04-28 03:11:55 +02:00
Daniel
b82db99ebc feat(mobile): biometric sign-in (Face ID / Touch ID / fingerprint)
Adds opt-in biometric login to the Capacitor app. Replaces the password
step on subsequent sign-ins; the 2FA step (if any) still applies — by
design, defense in depth.

How it works:
- After a successful password sign-in on a Capacitor build, prompt the
  user to enroll. If they accept, capacitor-native-biometric.setCredentials
  stores the (email, password) pair in the iOS Keychain / Android Keystore
  with biometric-protected access. The local flag ped_bio_enabled=1 is
  set so the next launch knows to probe.
- On the login form, if isNativeApp() + bioStored() + bioAvailable.ok,
  reveal the "Sign in with Face ID / Touch ID / fingerprint" button at
  the top. Label is set from the actual biometryType returned by the
  plugin so users see what their device supports.
- Tap → verifyIdentity (OS prompt) → getCredentials → fill the email +
  password fields → fire the existing form submit so all the regular
  flow runs (turnstile, 2FA prompt, error handling, session storage).
- Explicit logout deletes credentials AND clears the local flag,
  hiding the button on the next visit. Auto-logout (token expiry,
  network) does NOT come through that path, so biometric persists
  across silent session resets.

Storage choice — password not JWT:
- JWTs expire and the storage would constantly need refresh.
- Storing the password lets the standard /api/auth/login flow run,
  which already handles password-rotation (a stale stored password
  just fails 401 → user falls back to typing the new one → re-enrolls).
- The password sits in OS-level secure storage, accessible only after
  successful biometric verification — same security posture as a
  password manager autofill.

Files:
- mobile/package.json: add capacitor-native-biometric@^5.0.0 (Capacitor 6
  compat)
- mobile/android/app/src/main/AndroidManifest.xml: add USE_BIOMETRIC
  uses-permission
- mobile/ios/App/App/Info.plist: add NSFaceIDUsageDescription string
- public/js/auth.js: bioPlugin/bioAvailable/bioStored/bioEnroll/
  bioRetrieve/bioForget helpers; window.PedBio surface; reveal-on-load;
  click handler; post-login enrollment prompt; logout cleanup
- public/index.html: hidden #btn-bio-login + #bio-divider above the
  email field on the login form
- public/css/styles.css: themed gradient button + hover lift
- mobile/README.md: feature list updated

Build steps for Daniel:
  cd mobile && npm install        # picks up capacitor-native-biometric
  npx cap sync                    # ports the plugin into android/ + ios/
  # then build APK / IPA as usual
2026-04-28 03:09:38 +02:00
Daniel
b53aa34248 feat: ED multi-stage UX, extensions polish, docs viewer + application-logic docs
Three concurrent themes from this session:

═══════════════════════════════════════════════════════════════════
ED ENCOUNTERS — per-stage cards + consolidate→MDM finalize
═══════════════════════════════════════════════════════════════════

UX redesign per Daniel's feedback ("every stage note should be shown,
if AI is told to modify that particular note then the modified version
is used in final mdm"):

- Each generated stage stays on screen as its own editable card with
  its own embedded "Don't Miss" panel. No more single rolling note
  element that gets replaced on each generation.
- gatherCurrentNotes() reads contenteditable text from each stage card
  before any operation (advance, finalize, persist) so inline edits
  flow into the next AI call and the final consolidate.
- Stage badge is now state-accurate. "Stage N (recording)" with yellow
  background after Add-more before generation; "Stage N" with gray
  after generation. Fixes the bug where the badge flipped to Stage 2
  the moment Add-more was clicked.
- Save & Done now runs TWO server-side AI calls in /finalize:
  1. edConsolidate (new prompt) → polished single final note that
     integrates every stage chronologically (HPI / ROS / PE / ED Course /
     A&P with disposition).
  2. edFinalize (rewritten with full inline 2023 AMA E/M element
     rubric — problems / data / risk definitions, level mapping with
     concrete examples) → MDM JSON.
- Two new cards render after finalize: blue-bordered Final Consolidated
  Note + green-bordered MDM. Stage cards become read-only.
- partial_data on the saved row now stores {stages, finalNote, mdm,
  finalized} so resume re-renders the full state.

Why two-call finalize: a single combined prompt makes the model cut
corners on one task. Two focused calls cost ~2× latency at the very end
of an encounter — acceptable since finalize is a one-time terminal
action, not a per-stage hot path.

Files: public/components/ed-encounter.html, public/js/ed-encounters.js,
src/routes/edEncounters.js, src/utils/prompts.js (edConsolidate added,
edFinalize rewritten).

═══════════════════════════════════════════════════════════════════
EXTENSIONS / PAGERS — visual polish
═══════════════════════════════════════════════════════════════════

Multiple iterations based on Daniel's feedback:

- Layout: align-items:flex-start so action buttons stay pinned top-right
  when long numbers wrap (was align-items:center → buttons drifted into
  the text area, causing visible overlap).
- Number: word-break:break-all + min-width:0 + font-feature-settings:tnum
  so long numbers wrap within their column instead of pushing under the
  buttons. Click-to-copy with a 0.55s green flash + ✓ copied badge.
- Phone/pager Font Awesome icon next to the number in the type color —
  at-a-glance type signal (replacing an earlier 3px left stripe that
  Daniel found visually bulky).
- Name: font-weight 700, font-size 14.5px, color g900, letter-spacing
  -0.012em — scan-target headline typography for long lists.
- Alternating subtle backgrounds by index (white vs #fafbfc) so a long
  list reads as distinct rows.
- Hover: card lifts 1px with a soft shadow; action buttons fade from
  55% to 100% opacity. Cubic-bezier transition on transform.
- Entrance: staggered fade-up animation per card (35ms × index, capped
  at 12). prefers-reduced-motion media query disables motion.
- Empty state: 48px FA icon + heading instead of plain gray text.

Files: public/js/extensions.js, public/css/styles.css.

═══════════════════════════════════════════════════════════════════
DOCS REORGANIZATION + APPLICATION-LOGIC DOCS + ADMIN VIEWER
═══════════════════════════════════════════════════════════════════

Document moves (preserving git history via git mv):
  BROWSER_WHISPER_SETUP.md          → docs/browser-whisper-setup.md
  BROWSER_WHISPER_TROUBLESHOOTING.md → docs/browser-whisper-troubleshooting.md
  DEVELOPER_GUIDE.md                → docs/developer-guide-extended.md
  EMBEDDINGS_SETUP.md               → docs/embeddings-setup.md
  FEATURES_EXPLAINED.md             → docs/features-explained.md
  IMPROVEMENTS.md                   → docs/improvements.md
  OPENID_SETUP.md                   → docs/openid-setup.md
  TRANSCRIPTION_OPTIONS.md          → docs/transcription-options.md
README.md updated with the new paths + a Documentation section that
links to docs/logic/ at the top.

New application-logic doc series (~8,300 lines total) at docs/logic/.
Built with 5 parallel doc-writing agents per Daniel's "use multiple
agents" directive. Each doc explains how a part of the app actually
works — application logic, data flow, design decisions, sacred zones,
how-to-extend recipes — at a depth that lets a new dev (or an AI
assistant) modify the code confidently.

  docs/logic/README.md                — index + recommended reading order
  docs/logic/architecture.md (2166 L) — frontend IIFE pattern, lazy tab
                                         load, backend route convention,
                                         schema, encryption, deployment
  docs/logic/clinical-notes.md (1546L) — every note tab + helper trio
  docs/logic/bedside-and-calculators.md (1373L) — bedside ES module
                                         pocket + calculators + PE Guide
                                         + suture selector
  docs/logic/auth-admin-learning.md (1281L) — auth (local+OIDC+2FA) +
                                         admin panel + Learning Hub
                                         (Quiz engine logic at sub-detail
                                         only — TODO follow-up)
  docs/logic/ai-and-voice.md (1128 L) — callAI 5-provider routing,
                                         prompts, voice/STT, helper trio
  docs/logic/ed-encounters.md (821 L) — multi-stage ED + MDM (this
                                         session's worked example)

Admin-only docs viewer:
- New route /api/admin/docs/{tree,file}: recursively walks docs/, returns
  the tree as JSON; /file?path=X validates path stays inside docs/ and
  renders markdown via marked. Both gated by req.user.role==='admin'.
- New tab "Docs" (book icon) in the sidebar, hidden by default and
  revealed in auth.js when user.role==='admin' (same pattern as the
  existing Admin and CMS tabs).
- New component public/components/admin-docs.html: split-pane layout
  with a tree sidebar + filter input + a markdown reader pane.
- New module public/js/admin-docs.js: lazy-loads the tree on first tab
  activation, renders collapsible folders, persists expanded state and
  last-opened path via UIState. Server-rendered HTML so no client
  markdown parser needed.
- CSS for the viewer (responsive split-pane, code-block styling, table
  scrolling, etc.).
- Mounted at /api/admin/docs (NOT /api) — important: mounting a router
  with router.use(authMiddleware) at /api accidentally 401s every other
  /api/* path (caught and fixed during testing — /api/health was 401'ing).

Files: docs/* (moved + new), README.md, public/components/admin-docs.html
(new), public/js/admin-docs.js (new), src/routes/adminDocs.js (new),
public/index.html (tab + section + script), public/js/auth.js (admin
gate + logout cleanup), public/css/styles.css (viewer styles), server.js
(mount).

═══════════════════════════════════════════════════════════════════
KNOWN GAPS (TODO follow-ups)
═══════════════════════════════════════════════════════════════════

- Learning Hub quiz engine (MCQ / multi-select / T-F scoring + attempt
  tracking + progress dashboard) is covered at the architectural level
  in docs/logic/auth-admin-learning.md but not drilled into the quiz
  data model and scoring flow. Worth a focused follow-up doc.
- ED finalize: if MDM step JSON parse fails, server returns 502 with
  the consolidated finalNote in the error payload, but client doesn't
  surface the partial result. Add a "MDM failed, retry" affordance.
- No e2e Playwright coverage for ED encounters or the new docs viewer.
2026-04-28 03:09:38 +02:00
Daniel
4f129b24e1 feat: don't-miss tooltip after notes + bedside suture selector
#2 — Don't-miss tooltip (encounters HPI + sick visit, max 5)
- New POST /api/dont-miss returning {points: [{point, why}]} capped at 5
  (cap defended both in the prompt and server-side .slice(0,5))
- New dontMissTooltip prompt in prompts.js
- New suggestDontMiss() helper in app.js mirroring suggestBillingCodes;
  inserts an orange-bordered card next to the note output, silent on empty
- Wired into liveEncounter.js (encounter HPI) and sickVisit.js. Not added
  to wellvisit/soap/hospital/chart per spec.

#1 — Bedside suture selector
- New ES module public/js/bedside/sutures.js (~300L) following the
  burns.js pattern: site × age × tension × cosmetic × contamination ×
  hours-since-injury → material, size, technique, removal day range,
  glue/Steri-strip alternative, warnings, tetanus reminder.
- 15 anatomic sites covered (face, eyelid, lip vermilion, intraoral,
  ear, scalp, neck, trunk, upper/lower ext, hand, foot, joint surface,
  genitalia, fingertip).
- Bites: cat/human → don't-close-primarily warning; dog bite to hand →
  loose-approximation note. Heavy contamination → delayed primary
  closure. >12h non-face/scalp → judgment call note.
- Removal days shown as ranges (3–5, 7–10, 10–14) per source norms,
  not single midpoints.
- Subungual hematoma trephination guidance corrected: any painful
  hematoma with intact nail and no displaced fracture (especially if
  25–50% or more), per current UpToDate guidance.
- Inline citation: Roberts & Hedges 7e (2019), Fleisher & Ludwig 8e,
  AAP Section on EM, UpToDate (Pope JV).
- Pill registered in sub-nav SECTIONS + bedside/index.js. Persists
  active state via existing UIState helper.

All 46 tests pass.
2026-04-28 03:09:38 +02:00
Daniel
67b7667e04 feat: ED encounters + notes model selector; remove AI corrections; fix notes framing
Four changes batched:

1. ED Encounters tab (new) — multi-stage emergency note with don't-miss
   tooltips and 2023 E/M MDM finalize. New route /api/ed-encounters
   (generate per-stage + finalize MDM), new ed-encounters.js owning all
   client logic, new ed-encounter.html component, new template_ed memory
   category. Persists draft to localStorage every keystroke and to
   saved_encounters on stage advance. encounters.js touched only to
   register the new tab in sessionStorage restore + tabMap (save and
   idempotency code untouched).

2. Notes model selector — /notes/from-voice now accepts a client-supplied
   model (validated by the existing callAI allow-list); falls back to the
   admin default. Added <select class="tab-model-select"> to notes.html
   so the existing app.js populator handles options + default.

3. Remove AI-learning-from-corrections — deleted correctionTracker.js,
   POST /memories/correction, the corrections branch in
   /memories/context, the settings UI section, the FAQ entry, and all
   dead trackAIOutput/saveCorrection guards in callers. Legacy
   correction_* DB rows are filtered (NOT LIKE) rather than dropped, so
   no destructive migration.

4. Fix notes AI framing — /notes/from-voice prompt no longer assumes
   "physician dictation". Plain notes (shopping lists, reminders,
   ideas) now match the dictation tone instead of being forced into
   clinical structure.

All 46 tests pass.
2026-04-28 03:09:38 +02:00
github-actions[bot]
2872f1d063 Release v6.53.2 2026-04-24 23:44:32 +00:00
Daniel
dccb3b4bcb fix(notes): Stop button never transcribed — silent-cancel branch was always taken
Root cause for "note recording, not working nor going into textbox / no
progress etc". The click handler was wired with:

  recStop.addEventListener('click', stopRecording);

addEventListener invokes the handler with the click Event as the first
argument. My stopRecording(silent) signature uses that first arg as a
boolean — and a non-null Event is truthy, so every Stop click hit the
silent-cancel branch (mic off, stream closed, UI back to idle, but no
.then-chain fires, no transcribeAudio, no /api/notes/from-voice).

Symptom matched exactly: click Dictate → record → click Stop → mic
indicator vanishes → editor stays empty → no Network activity for
/api/notes/from-voice. Server logs from earlier transcribes were all
from the Encounter / Dictation tabs (untouched flows), never Notes.

Fix: wrap all three voice-button handlers so the Event isn't passed
through. Only stopRecording cared about the first arg, but wrap all
three for symmetry and so future signature changes don't reintroduce
the same class of bug.

  recStart → function() { startRecording(); }
  recPause → function() { togglePauseRecording(); }
  recStop  → function() { stopRecording(false); }

SW cache bumped to pedscribe-v12-notes7.

About the model: /api/notes/from-voice already reads `models.default`
from app_settings (whatever you picked in Admin Panel → Models →
Default Model). Confirmed in your DB it's set to
"openrouter-vendor-model-sonnet-4.6". No new admin UI added.
2026-04-25 01:44:23 +02:00
Daniel
7ed8a2365b chore: ignore .codex workspace marker (accidentally committed in 66d4f1f) 2026-04-25 01:35:27 +02:00
github-actions[bot]
9106d85f98 Release v6.53.1 2026-04-24 23:35:20 +00:00
Daniel
df1a6613dd fix(notes): voice → AI note now produces HTML the editor can render
Symptom Daniel reported: "note recording, not working nor going into
textbox". Root cause was on the server side — /api/notes/from-voice
asked the AI for HTML in its prompt, but real-world models return
markdown ~25% of the time. Tiptap's setContent only renders HTML;
markdown comes through as literal text or a partial render, looking
like the textbox didn't fill.

Server (src/routes/notes.js):
  • New toHtmlBody() helper. If the AI returned real HTML (any
    block tag), pass through. Otherwise run through `marked` so
    markdown becomes <p>/<h*>/<strong>/etc.
  • Strips ```json / ```html code fences before JSON parsing.
  • Stricter JSON-recovery: only accepts {title|body} parsed shape;
    falls back to wrapping the AI's full reply via toHtmlBody().
  • Final guard: if body would be empty after sanitisation, wrap
    the raw transcript so the user can at least edit it manually.

Client (public/js/notes.js):
  • applyGeneratedNote prefers _editor.commands.setContent over a
    full remount — avoids the toolbar-reattach flicker + the brief
    window where the body looked empty.
  • Logs to console when the editor target is missing or Tiptap
    setContent throws, so a future regression is greppable.

Plus the two infra fixes Daniel approved earlier in the same
session — keeping them in this commit since they're already
deployed and tested:

  • src/db/database.js: cleanup interval handle exposed; server
    shutdown now clearInterval()s it before pool.end(). Removes
    the SIGTERM → 9-second-hang → Docker SIGKILL race.
  • src/routes/audioBackups.js: switch multer.memoryStorage() to
    diskStorage with cleanup. 10 concurrent 25 MB uploads no
    longer pin 250 MB of RAM. Identical user-visible perf since
    upload is wire-bound.

New dep: marked@latest (used server-side only in toHtmlBody).
2026-04-25 01:35:10 +02:00
github-actions[bot]
fd5108e7b3 Release v6.53.0 2026-04-24 23:02:58 +00:00
Daniel
9961688bfa feat(notes): trash + restore + DOMPurify sanitizer + 9 contract tests
Soft-delete for notes — Daniel asked for "deleted notes go to trash"
so a slip of the finger doesn't lose work.

Schema: migrations/1777090000000_notes-trash.js adds a deleted_at
timestamptz column to personal_notes (NULL = active) plus an index
on (user_id, deleted_at).

Server (src/routes/notes.js):
  GET    /api/notes              now filters deleted_at IS NULL
  GET    /api/notes/trash        new — list trashed items, newest-
                                  deleted first
  DELETE /api/notes/:id          now soft-deletes (sets deleted_at)
  DELETE /api/notes/:id?hard=1   hard-delete, only allowed on items
                                  already in trash (UI bug can't
                                  erase an active note)
  POST   /api/notes/:id/restore  pull a note out of trash
  POST   /api/notes/trash/empty  hard-delete every trashed note for
                                  the user

Frontend (public/components/notes.html + public/js/notes.js +
public/css/styles.css):
  • Sidebar gets two tabs — "Notes" / "Trash (n)" with live count
  • Trash tab shows deleted-at timestamps, Restore + delete-forever
    per row, Empty-trash button at the bottom
  • Active list and trash count refresh in parallel after every
    save / delete / restore
  • Delete button in the editor now says "Move to trash" and uses
    the showConfirm helper (no native dialogs)

Sanitizer swap (public/js/notes.js):
  Replaced the homegrown allowlist walker with DOMPurify (already
  loaded from cdnjs in index.html, used by learningHub.js too).
  Custom HTML sanitizers historically have bypasses; DOMPurify is
  the right primitive.

Tests (test/notes-sanitize.test.js — node:test + jsdom + dompurify
       as new dev deps):
  9 contract tests covering script-tag stripping, inline event
  handlers, img onerror, iframe/object, style attributes, every
  preserved tag in the allowlist, javascript: URI rejection,
  null/undefined input, and nested-script-inside-paragraph. Total
  test count: 37 → 46 passing.

SW cache bumped to pedscribe-v12-notes5.
2026-04-25 01:02:49 +02:00
github-actions[bot]
8c9c03c656 Release v6.52.1 2026-04-24 11:22:36 +00:00
Daniel
2a4269d496 fix(notes): 5 bugs found in post-commit review
1. flushAutosave dropped brand-new unsaved notes.
   The guard was `if (_dirty && _activeId != null)` but _activeId is
   null until the first POST succeeds. So tap-New-Note → type → tap-
   Back went through flushAutosave → skipped → closeToList → lost
   the typed content. saveNote() already handles isNew via POST;
   the guard only needs to check _dirty.

2. beforeunload sendBeacon used wrong HTTP method.
   navigator.sendBeacon is POST-only by spec, but my code used it to
   target PUT /api/notes/:id. Beacons silently hit a 404 route and
   the note didn't save. For brand-new notes the URL resolved to
   /api/notes/undefined. Replaced with fetch({ keepalive: true })
   which browsers queue + ship even across unload, supports PUT,
   and handles POST for new notes correctly.

3. Mobile: empty-state card stacked below the sidebar in list view.
   The mobile media-query hid .notes-reader + .notes-editor when
   data-view="list" but didn't include .notes-empty-state, so the
   big "new note" card appeared below the list on phones. Added
   the missing selector.

4. Delete fallback used window.confirm.
   The `else if (window.confirm(...))` branch in deleteActive was
   a rule violation even as a fallback — feedback_no_native_dialogs
   says no native dialogs anywhere in frontend. Dropped it;
   showConfirm is loaded by app.js before any tab activates so the
   fallback path is unreachable in practice. Also upgraded the
   confirm call to use the danger + confirmText options so the
   modal renders red "Delete" button instead of neutral "Confirm".

5. Recorder kept running after switching tabs or notes.
   Dictating, then tapping another clinical-tools tab (or opening
   another note) left the MediaRecorder active and the mic stream
   open — wasted battery, privacy surprise. Added stopRecording
   (silent=true) to the non-notes branch of tabChanged, to
   startCreate, and to openReader so any in-flight recording
   cancels cleanly when the user moves on.

Minor:
  • Dead "'Saving…' : 'Saving…'" ternary in saveNote simplified.

SW cache bumped to pedscribe-v12-notes4.
2026-04-24 13:22:26 +02:00
github-actions[bot]
a25c36c875 Release v6.52.0 2026-04-24 10:49:31 +00:00
Daniel
2d89f295dd feat(notes): strip explanatory UI copy
Removed the "your note saves as you type" / "encrypted at rest" /
tip-list filler that telegraphs AI-generated code. UI now reads
the way a real app reads — names where names go, no paragraphs
explaining what a button does.

  • Module header: "Notes" — removed the paragraph tagline.
  • Empty state: icon + "New note" button only — removed the
    "Hello 👋" heading, the description, and the three-tip list.
  • Placeholders: "Title" / "Search" — removed the "…" ellipses
    and the "Note title" / "Search notes" verbosity.
  • Status + meta: emptied the "New note — will save automatically
    once you type" meta string and the "This note is empty. Tap
    Edit to add content" reader placeholder.
  • Empty-list copy trimmed to just "No matches" when the search
    filter is active.

SW cache bumped to pedscribe-v12-notes3.
2026-04-24 12:49:22 +02:00
github-actions[bot]
fd658739c3 Release v6.51.0 2026-04-24 10:45:40 +00:00
Daniel
9bb4879be1 feat(notes): read-mode default + Edit toggle + autosave + mobile layout
Four changes landing together so the UX flows properly.

1. Fix voice-generation model error.
   /api/notes/from-voice was passing `req.body.model` (undefined)
   through to callAI, which resolved to LITELLM_DEFAULT_MODEL (empty
   for LiteLLM deployments with no explicit default) → `model=""` →
   LiteLLM 400 "Invalid model name". Server now reads the admin-
   configured `models.default` setting, falls back to the
   LITELLM_DEFAULT_MODEL env var, and only passes a model if one
   resolved. Empty string never reaches the provider.

2. Read mode as the default post-save.
   Opening an existing note or saving a new one now lands on a
   clean, read-only rendering of the body (sanitized HTML with an
   allowlist — p/h2/h3/strong/em/u/s/a/ul/ol/li/blockquote/code/pre).
   Click the "Edit" button to switch to the Tiptap editor. Matches
   the mental model of "notes are documents I review, not drafts I'm
   always editing."

3. Autosave during edit.
   Title-input + Tiptap onUpdate trigger a 1.2-second-debounced save.
   In-flight saves coalesce: if the user types more while a save is
   in progress, a follow-up fires right after it lands so the last
   keystroke never gets stranded. beforeunload uses navigator.
   sendBeacon to best-effort flush on tab close. The manual Save
   button is kept as a "save + switch to reader" shortcut. Ctrl/
   Cmd+S still works in the editor.

4. Mobile layout.
   The two-pane layout collapses to a single-pane view below 900px,
   driven by a data-view attribute on .notes-layout. Back buttons
   appear in the reader + editor heads on mobile to return to the
   list. Desktop layout unchanged (sidebar + right pane always
   visible).

Also:
  • CSS specificity fix — .hidden{display:none} was losing to
    .notes-rec-indicator{display:inline-flex} on source order, so
    the "Recording" indicator was showing when idle. Added explicit
    .notes-rec-indicator.hidden + .notes-voice-bar .btn-sm.hidden
    overrides at higher specificity.
  • Reader-body sanitizer — allowlist of safe tags + attributes;
    <a> links get target=_blank + rel=noopener.
  • SW cache bumped to pedscribe-v12-notes2 so clients pick up the
    new module / component / CSS.
2026-04-24 12:45:30 +02:00
github-actions[bot]
415f67d432 Release v6.50.0 2026-04-24 04:18:56 +00:00
Daniel
3c0de624fc feat(notes): personal notes with rich-text editor + voice dictation
New "Notes" tab under Clinical Tools — a per-user scratchpad that's
explicitly NOT fed into AI prompts (distinct from user_memories).
Two-pane layout: searchable list on the left, rich-text editor with
title + save/edit/delete on the right.

Backend:
  migrations/…_add-personal-notes.js  — personal_notes table
    (id, user_id → users ON DELETE CASCADE, title, body, created_at,
    updated_at) with indexes on user_id + (user_id, updated_at).
  src/routes/notes.js — CRUD + one AI endpoint:
    GET    /api/notes           list, newest-updated first
    GET    /api/notes/:id       fetch one
    POST   /api/notes           create (title + body required)
    PUT    /api/notes/:id       update
    DELETE /api/notes/:id       remove
    POST   /api/notes/from-voice  transcript → { title, body }
                                   via callAI (admin-controlled
                                   provider — never selectable by
                                   the clinician).
    Body + title encrypted at rest via the same cryptoUtil used for
    user_memories; 500-note per-user cap; 200-char title / 50 KB
    body limits.

Frontend:
  public/components/notes.html — empty-state card ("Hello 👋"),
    sidebar list with search, editor head with voice-bar (Dictate /
    Pause / Resume / Stop + live timer + pulse indicator), Tiptap
    body, metadata footer. Uses existing .tp-* toolbar classes.
  public/js/notes.js — lazy-init on first tab activation; Tiptap
    editor built from window.Tiptap (same bundle the Content
    Manager uses); delegated list clicks; Ctrl/Cmd+S to save;
    uses app.js's AudioRecorder + transcribeAudio so the STT
    pipeline is shared. On stop → transcribe → /api/notes/from-
    voice → drop the AI-structured title + body into the editor;
    clinician reviews then saves.
  public/css/styles.css — 70 lines of .notes-* styles (card
    layout, warm empty-state with gradient icon + tips, pulse
    animation for the recording indicator, focus states, hover
    nudges).
  public/sw.js — bump cache from pedscribe-v12 → pedscribe-v12-
    notes1 so clients pick up the new module/component.

Admin-controlled STT provider: the recorder posts its audio blob
to the existing /api/transcribe (Google / LiteLLM / ElevenLabs /
Browser Whisper — whatever admin wired up in Settings). Users
cannot pick the provider from this UI.
2026-04-24 06:18:41 +02:00
Daniel
d71714b65d test(lint): static reference linter — catches dead-code + orphan refs
You were right that Playwright has been catching the easy bugs while
the high-signal bugs (lightbox stranded after the Bedside reorg, PVC
clinically wrong, prod not rebuilt) all had to be caught by you as
the user. Adding a static linter so the class of bug that produced
the lightbox regression fails CI next time instead of the app.

scripts/lint-references.js walks public/js and validates every
getElementById('X') and querySelector('#X') resolves to an id that
is defined SOMEWHERE in the repo — either a static HTML attribute,
a .id = 'X' assignment, or an id="X" substring inside a JS template
string. It also walks HTML for asset references (data-img-src,
<img src>, <audio src>, <link href>) and verifies each root-absolute
path maps to a real file on disk.

Running it on the current tree surfaced two real problems:

1. shadess.js:917 reached for #wv-note-transcript when collecting
   refine source context. The actual id is #wv-transcript (no -note-
   infix — the transcript element is shared across well-visit sub-
   panels). The bug meant a generated well-visit note's Refine call
   silently missed the original transcript as source context; the AI
   still "worked" but with less signal and no error. Fixed.

2. public/js/adminMilestones.js (221 lines) referenced 17 ids that
   were removed a year+ ago in commit 3173ce6 ("Remove milestone
   admin UI, add CMS content refresh button"). That commit dropped
   the HTML but forgot the JS, which has been dead-loaded on every
   page view since. All its addEventListener calls are guarded with
   optional chaining, so nothing errored — just silent cruft.
   Deleted the file and dropped the <script defer> tag from
   index.html.

scripts/e2e.sh runs the linter as a preflight before the Playwright
container starts; a broken reference now fails the suite before any
test even boots.

Allowlist is kept small and prefix-based for the handful of id families
that are built dynamically by JS (bedside em-* sections, BP chart
elements, etc.). When a new component is added, its ids get picked up
automatically by the repo-wide collect() pass; the allowlist rarely
needs to grow.

Suite: 294 passed / 0 failed.
2026-04-23 18:58:38 +02:00
Daniel
abc1a64363 test(e2e): +44 tests — sick visit, hospital course, auth screen,
session persistence, per-tab model selector

Brings detailed coverage to sections that were previously only smoke-
tested:

- sickvisit-workflow.spec.js: generate → mocked note, refine bar
  round-trip, load popover, New button clears demographics + transcript.
- hospitalcourse-workflow.spec.js: fill H&P → generate → mocked
  narrative renders via /api/generate-hospital-course, refine updates
  text, load popover open/close.
- auth-screen.spec.js: unauthenticated landing visible, login form
  structure, forgot-password swap + return, register link is
  intentionally display:none on this instance (pinned), register form
  DOM still wired correctly if the link is manually unhidden, reg-
  password has minlength=8 + type=password.
- session-persistence.spec.js: clear cookie simulates logout (UI login
  is gated by Turnstile which can't be completed in the e2e container);
  re-login via loginAs restores the last tab + sub-pill via localStorage.
- model-selector.spec.js: tab-model-select dropdowns render with >0
  options across 7 tabs.

Fixture corrections:
- /api/sick-visit/note and /api/well-visit/note were not being mocked
  at all — the wrong /api/generate-sick-visit pattern was intercepting
  nothing, so tests fell through to the real AI backend. Both now have
  correct patterns and response shapes (sick-visit returns `note`,
  well-visit note returns `note`).
- /api/generate-hospital-course response key updated from `narrative`
  to `hospitalCourse` to match what the frontend actually reads.

wellvisit-workflow: the Visit Note test now asserts a concrete
waitForResponse on /api/well-visit/note + text render, instead of the
previous "hit either endpoint" fallback.

Suite: 294 passed / 0 failed in 5m30s.
2026-04-23 18:58:38 +02:00
Daniel
9c53e19d29 fix(pe-guide): remove PVC entry from cardiac sounds library
PVCs are an ECG / rhythm finding, not a routine auscultation sample —
what you actually hear on the stethoscope is an irregular rhythm with a
compensatory pause, which depends on the underlying rate and is not
teachable from a canned audio clip. The card was also backed by a
synthesized sound, not a real recording. Removing both the card and
the pvc.ogg asset.
2026-04-23 18:58:38 +02:00
Daniel
1b209b5eb7 fix(nav): move image lightbox to index.html so NRP + seizure pathways
open from the Bedside tab

The #img-lightbox overlay markup was sitting at the bottom of
calculators.html. Before the reorg it was fine — the calculators tab
was always the only home for bedside, so by the time a user clicked
the seizure or NRP pathway button the lightbox HTML was guaranteed to
be in the DOM. After promoting Bedside to its own tab, a user can
open Bedside -> Seizures without having visited Calculators first;
the lightbox JS's getElementById('img-lightbox') then returns null
and the click silently no-ops.

Moved the overlay markup to the bottom of index.html so it exists
from page load regardless of which tab has been lazy-loaded. The e2e
harness gets a duplicate copy so the existing lightbox smoke test
keeps working.

Added a regression test for NRP (bedside-smoke.spec.js:141) to catch
any future breakage — the prior seizure-only test didn't exercise the
second pathway button and so the neonatal/NRP path had never been
clicked in CI.

Suite: 252 passed / 0 failed.
2026-04-23 18:58:38 +02:00
Daniel
2e517a67a9 feat(ui-state): persist sub-pill selections across reload + sign-out
Tab-level choice (ped_last_tab) already survived sign-out/in via
localStorage, but sub-pill and sub-tab selections inside a loaded tab
lived only in memory — they reset to defaults after a reload or
browser restart. Now the following are persisted under the ped_ui/
namespace:

  - Calculators nav pill (BP / BMI / GCS / …)
  - Bedside sub-pill (neonatal / airway / …)
  - Well Visit sub-tab (byvisit / milestones / shadess / note)
  - Physical Exam Guide age group + system

Implementation:
- Added public/js/ui-state.js — a ~30-line window.UIState wrapper
  around localStorage with a ped_ui/ prefix and try/catch around both
  read and write (Safari private mode + quota errors silently no-op).
- Each tab's click handler now also calls UIState.set; each tab's
  init path calls UIState.get and replays the saved value through
  the same function a click would call — so there is exactly one
  code path for "show this selection", whether it came from the user
  or from a restore. For Bedside, the restore additionally listens
  for tabChanged so the lazy-loaded HTML is guaranteed to exist by
  the time we re-activate the pill.

Tests:
- e2e/tests/ui-state-persistence.spec.js — 5 specs × 2 viewports =
  10 tests. Each clicks the feature, reloads the page, and asserts
  the same pill / subtab / dropdown value is still active. Catches
  any future regression in the persistence wiring.
- e2e/tests/soap-hospital-workflow.spec.js — fills SOAP transcript,
  generates via mocked AI, clears, opens/closes load popovers; also
  smoke-tests the Hospital Course save-bar.

Suite: 250 passed / 0 failed (+ 20 over the last run).
2026-04-23 18:58:38 +02:00
Daniel
8d97b13bf7 feat(nav): promote Bedside to its own tab; reorganise sidebar sections
Bedside is now a top-level tab instead of a sub-pill inside Calculators —
it's the highest-traffic emergency reference in the app and deserves a
one-click entry. Same DOM structure and JS modules; only the container
moved.

Sidebar reorg:
- Notes: Hospital Course, Chart Review, SOAP Note, Well Visit, Sick Visit
  (Well Visit + Sick Visit relocated from the old "Pediatric" group —
  they're clinical note workflows, not pure reference tools).
- Section rename: "Pediatric" → "Clinical Tools". The section now holds
  Vaccine Schedule, Catch-Up Schedule, Physical Exam Guide, Bedside,
  Calculators, Pagers & Extensions, Learning Hub, Content Manager — mix
  of reference tables + active calculators + utilities, none strictly
  pediatric. "Clinical Tools" reads naturally for the combined set.

Layout changes:
- calculators.html: dropped 480 lines of bedside panel + the Bedside
  nav-pill. The shared age→weight estimator moved with it.
- bedside.html: new component file, contains the full bedside card +
  the age→weight estimator prepended.
- index.html: added bedside-tab section, sidebar restructured.
- e2e-harness.html: renders calculators + bedside side-by-side (not the
  old calc-tab→bedside-pill dance) so the bedside smoke suite still
  works without auth. e2e-bootstrap fetches both with a cache-buster.
- bedside-smoke.spec.js: removed the now-obsolete calc-nav-pill click
  from each test.

Tab persistence is unchanged — ped_last_tab already survives sign-out,
and the lazy-load cache keeps sub-pill state across navigation within a
session. Persistence across browser restart for sub-pills is a separate
follow-up.
2026-04-23 18:58:38 +02:00
Daniel
3884bf673b test(e2e): +120 tests across 8 new specs; baseline fixes
8 new spec files covering sections previously only smoke-tested:
- ai-endpoints-contract.spec.js  — hits 8 real AI endpoints via request
  context and fails if the response leaks TypeError / ReferenceError /
  'Cannot read properties of undefined' / 'is not defined' / 'is not a
  function'. This is the class of bug that shipped the PE-narrative
  regression to prod because every page-level mock prevented the real
  handler from running.
- encounter-workflow.spec.js — generate HPI, refine, clear transcript.
- encounter-save-load.spec.js — save draft, load popover, repopulate.
- wellvisit-workflow.spec.js — byvisit, milestones, SSHADESS (12+
  reveal), visit note.
- vaxschedule-content.spec.js — schedule + catch-up panels populate
  beyond "Loading".
- chart-review-workflow.spec.js — generate + load popover.
- learning-tab.spec.js — search filter, category pills, feed.
- settings-faq-dictation.spec.js — voice/password/2FA/Nextcloud
  sections, FAQ expand/collapse, dictation generate flow.

Baseline fixes:
- Added CORS_ORIGINS + API_RATE_LIMIT_MAX env overrides so the e2e
  container accepts the browser's Origin header and can absorb the
  full suite's API traffic without tripping the 200/min guard.
- Server's /api/ rate limit is now configurable via
  API_RATE_LIMIT_MAX (default stays 200).
- extensions-crud: replaced native page.on('dialog') listeners with
  #confirm-modal-ok clicks (we moved off native confirm()).
- pe-guide-smoke + extensions-crud: mobile viewport opens the hamburger
  before clicking sidebar tabs.
- fixtures.js: /api/refine mock uses 'refined' (real API shape), not
  'content'. /api/chart-review replaced with /api/generate-chart-review.

Suite: 230 passed / 0 failed in 4m36s.
2026-04-23 18:58:38 +02:00
Daniel
015eaf9945 feat(pe-guide): real lung recordings for normal/rhonchi/pleural-rub;
drop grunting + dead synth module

Three lung sounds that were Web-Audio syntheses now play real clinical
recordings sourced from the HLS-CMDS manikin dataset (MIT license):

- normal-vesicular.ogg
- rhonchi.ogg
- pleural-rub.ogg

Dropped expiratory grunting from the library entirely — no
openly-licensed clinical recording located across Wikimedia, Freesound
CC0, SPRSound, Pixabay, Internet Archive, or Littmann/EasyAuscultation
(all proprietary). Card is honest by omission rather than hiding a
synth behind a Play-only UI.

All seven remaining entries now use the same native <audio controls>
player (pause, seek, volume). The synth fallback branch in
renderSoundCard, the stopAllExcept synth reset loop, the script tag,
and the entire public/js/respiratorySounds.js (332 lines of Web Audio)
are removed since nothing references them anymore.
2026-04-23 18:58:38 +02:00
Daniel
02e7281e52 fix(pe-guide): correct route imports (prod 500 regression)
The route destructured PROMPTS/INJECTION_GUARD/wrapUserText from
../utils/prompts, but PROMPTS is the module's default export and the
other two live in ../utils/promptSafe. All three resolved to undefined,
so every /api/generate-pe-narrative request crashed with
"Cannot read properties of undefined (reading 'peGuideNarrative')"
and the client surfaced "Request failed" for PE Guide narrative and
summary generation. Split the require into the two-line form that
every other AI route already uses.
2026-04-23 18:58:38 +02:00
github-actions[bot]
fda5b12143 Release v6.20.0 2026-04-22 20:57:21 +00:00
Daniel
8cfa07dcf5 feat(pe-guide): unified single-play + remove badges; fix(e2e): shared fixture + raised login limit
User-visible changes:
- Removed the REAL / SYNTH badge from each sound card. User feedback:
  "ridiculous". Cards now just show the sound title + description +
  player, no distinction beyond the player type (native <audio controls>
  for recordings, play/stop/progress bar for synth).
- Removed the "real recordings; synth labelled SYNTH" subtitle from
  the sounds library header.
- Single-playback policy across the whole PE Guide: when any sound
  starts (audio or synth), every other playing sound stops. Covers:
  audio → audio (pause the previous), audio → synth, synth → audio,
  synth → synth. Listeners attach on each .pe-audio 'play' event and
  on every synth play-button click.

E2E infrastructure fixes (for the test failures we hit):
- auth-gated-smoke.spec.js now imports test + loginAs from the shared
  fixtures.js so the token cache is unified across every spec. Without
  this, each spec file's module-scoped _tokenCache multiplied logins
  and hit the 10/15min rate limit.
- server.js: /api/auth/login rate limit is now configurable via
  LOGIN_RATE_LIMIT_MAX env var (default 10, prod unchanged).
- docker-compose.e2e.yml: LOGIN_RATE_LIMIT_MAX="500" so Playwright's
  two-project (chromium + mobile-chrome) multi-worker runs can do
  their logins without tripping the cap. Prod container unaffected.
- fixtures.js console.error allowlist expanded to suppress known
  non-bugs: Cross-Origin-Opener-Policy warnings on http:// e2e
  server, transient 401/403/404/503 resource loads, ERR_BLOCKED_BY_CLIENT.
2026-04-22 22:57:11 +02:00
github-actions[bot]
b80a91e40a Release v6.19.0 2026-04-22 19:51:04 +00:00
Daniel
2913b09abd feat(pe-guide): 7 cardiac sounds, APTM image full-width + tap-to-zoom, synth stop/progress
Three user-flagged fixes:

1. Cardiac sounds library expanded from 3 to 7 (from Wikimedia Commons
   heart-sounds + heart-murmurs subcategories). All real recordings:
   - Normal (61 bpm)                 [existing]
   - Infant heartbeat                [new — pediatric reference]
   - VSD                              [existing]
   - Mitral valve prolapse            [existing]
   - Still's murmur in a toddler      [new — classic innocent murmur]
   - Functional murmur (adult female) [new — benign flow murmur]
   - PVCs                             [new — arrhythmia]
   All with native <audio controls> (play/pause/seek/elapsed/total
   work on desktop AND mobile for free).

2. APTM diagram is now full-width on every device, no longer sharing
   row space with the legend on mobile:
   - Image always on its own row, max-width:420px, centered
   - Wrapped in an <a href target="_blank"> — tap/click opens the PNG
     full-size in a new tab where browser pinch-zoom works natively
   - "Tap to open full-size" hint line under the image
   - Legend below uses auto-fit minmax(min(100%,260px),1fr) so it stacks
     on narrow viewports without cramping

3. Synth sounds (rhonchi, pleural rub, grunting, normal vesicular — all
   Web-Audio-API generated, no native controls) now have:
   - Play button → kicks off playback and hides itself
   - Stop button appears, lets user terminate early
   - Progress bar animates over 3.2 s, resets at end
   - Only one synth sound plays at a time (clicking another stops the
     previous)
2026-04-22 21:50:54 +02:00
github-actions[bot]
bf62d15ad6 Release v6.18.0 2026-04-22 19:10:45 +00:00
Daniel
9bbfb4ce83 feat(pe-guide): native audio controls + cardiac sounds library + mobile layout
Three user-flagged issues fixed together:

1. Mixed real/synth audio was labelled only "synthesised samples" — misleading
   since wheeze, stridor, fine+coarse crackles are real Wikimedia recordings.
   Now each sound card has a REAL or SYNTH badge and the library header
   reads: "real recordings where available; synthesised approximations
   labelled SYNTH".

2. No murmur sounds in the CV section. Added a Cardiac sounds library
   between the APTM diagram and the innocent-murmur panel:
     - Normal heart sounds (S1, S2) — 61 bpm reference
     - Ventricular septal defect (VSD) — harsh holosystolic at LLSB
     - Mitral valve prolapse (MVP) — mid-systolic click + late systolic
   All 3 are real recordings from Wikimedia Commons.

3. No pause / stop / duration controls. Replaced the synth-only play
   button with a native <audio controls> element for every real
   recording — gives play, pause, seek, elapsed/total time, volume
   for free on desktop AND mobile (browser-native, accessibility-
   compliant, consistent with platform conventions). Synth sounds
   (rhonchi, pleural rub, grunting, normal vesicular) keep the one-
   shot play button since they\'re Web-Audio-API generated and don\'t
   support seeking.

Mobile layout:
- The APTM diagram + legend was hard-coded 2-col (minmax(280px,1fr) 1fr)
  which could overflow narrow screens. Switched to repeat(auto-fit,
  minmax(280px,1fr)) — stacks to 1-col below ~580 px viewport.
- Refactored sound-library + APTM render into helpers (renderSoundCard,
  renderSoundsLibrary, TWO_COL_GRID constant) to reduce duplication.
2026-04-22 21:10:36 +02:00
github-actions[bot]
7d860c5287 Release v6.17.1 2026-04-22 19:04:54 +00:00
Daniel
b7a2e15107 fix(entrypoint): docker-compose env overrides win over OpenBao-fetched values
The previous entrypoint unconditionally exported every key from
kv/ped-ai/prod. This broke the e2e container, which needs
TURNSTILE_SECRET_KEY="" and SMTP_HOST="" set via docker-compose
environment block so login works without bot challenge and register
auto-verifies. OpenBao's real values were overriding those empties,
re-enabling Turnstile and email on e2e.

Fix: before the OpenBao fetch, snapshot every env var name already
defined (env_file + environment: block). During the export loop,
skip any OpenBao key that's already in the snapshot. Docker-compose
wins, OpenBao fills in the rest.

Impact:
- Prod container: no change (env_file only has OPENBAO_* bootstrap
  vars, which aren't in the KV payload anyway)
- E2e container: TURNSTILE_SECRET_KEY="" and SMTP_HOST="" preserved
  even when the image is rebuilt from the current source tree
- Any future per-container override via docker-compose environment:
  block just works

Log line now reports counts: "applied N secrets; M already set by
docker (kept override)".
2026-04-22 21:04:46 +02:00
Daniel
abb67bd03a test(e2e): PE Guide smoke — 8 tests covering all systems + age-group coverage
Uses the shared fixture so pageerror + console.error fail the test —
would catch any regression of the bug-class that shipped the SSO
ReferenceError. All tests log in as the seeded e2e user and drive the
real UI.

Coverage:
- tab loads with empty-state before age selected
- MSK (default) renders with scales + steps
- switch to Neuro shows MRC scale + teaching pearl
- Respiratory shows 8-sound library with play buttons + RR scale
- Cardiovascular shows APTM image (naturalWidth > 0 — actual network
  fetch confirmed), all 5 landmark letters, innocent-murmur panel,
  7 "S" criteria footer
- parametrised: every age group × {resp, cv} must NOT show "no data",
  must have ≥ 1 step (catches the regression Daniel just flagged)
- step toggle cycles Normal → Abnormal → Skip with visual state change
  and note-field show/hide
- grading scales <details> is collapsible and expands on click
2026-04-22 21:03:13 +02:00
github-actions[bot]
8cabe7da4b Release v6.17.0 2026-04-22 19:00:54 +00:00
Daniel
8be268df6a feat(pe-guide): real audio, innocent-murmur panel, APTM image, all-ages resp+CV
Four changes rolled together:

1. REAL AUDIO from Wikimedia Commons (CC BY-SA 3.0, attribution to follow
   in privacy policy per Daniel). Embedded in /public/audio/ — synthesis
   stays as fallback for sounds not available from Wikimedia.
   respiratorySounds.js now tries the real OGG first; falls back to Web
   Audio synthesis if file missing.

2. REAL APTM IMAGE from Daniel's Nextcloud share, placed at
   /public/images/pe-guide/aptm.png (134 KB PNG). Replaces the inline SVG.
   Kept the side legend with A/P/E/T/M colour-coded points.

3. INNOCENT MURMUR REFERENCE PANEL below the APTM diagram with the 5
   classic innocent murmurs (Still's, pulmonary flow, venous hum, carotid
   bruit, PPS) — age, location, character, confirming maneuver — plus
   the 7 "S" criteria summary.

4. ALL-AGE RESP + CV DATA. Before: only adolescent. Now every age group
   has age-appropriate resp + cv content (newborn through adolescent).
   Newborn: Silverman, pre/postductal sats, duct-dependent lesion screen.
   Infant: bronchiolitis, CHF diaphoresis, VSD, early CHD.
   Toddler: croup/FB/epiglottitis, innocent murmur peak age.
   Preschool + school-age: adult-pattern transition, sports screening.
2026-04-22 21:00:42 +02:00
github-actions[bot]
1aa785068b Release v6.16.0 2026-04-22 18:15:16 +00:00
Daniel
316a5e0338 feat(pe-guide): Cardiovascular system with APTM auscultation SVG
Fourth PE system added. Adolescent cardiovascular exam at the same
teaching-focused depth as respiratory/neuro: five components with
significance + pearls + detailed step methods + watch-for blocks.

APTM auscultation diagram (new, inline SVG, no image file):
- Stylised anterior chest with sternum, clavicles, ICS level lines,
  left mid-clavicular line
- Five colour-coded landmarks:
   A  Aortic    — 2nd ICS right sternal border
   P  Pulmonic  — 2nd ICS left sternal border
   E  Erb's pt  — 3rd ICS left sternal border
   T  Tricuspid — 4th ICS left sternal border
   M  Mitral    — 5th ICS mid-clavicular (apex)
- Side legend: location + what to listen for at each point
- Patient's-left / patient's-right labels to prevent mirror-image
  confusion

CV-specific grading scales (3 new entries in SCALES):
- Murmur grade Levine 1–6
- Pulse amplitude 0–4+
- Capillary refill time thresholds

CV components:
1. Inspection (general appearance, central/peripheral cyanosis,
   clubbing with Schamroth sign, precordial bulge, visible apex, JVP)
2. Palpation (apex position + character, parasternal heave, thrills
   at all 5 points, peripheral pulses upper + lower, radio-femoral
   delay for coarctation)
3. Auscultation — approach (positioning, diaphragm vs bell, systematic
   walk through all 5 points, left lateral decub for MS, leaning
   forward for AR)
4. Auscultation — heart sounds + murmurs (S1, S2 split, S3/S4 gallops,
   murmur characterisation by timing/location/radiation/character,
   Levine grading, dynamic maneuvers, innocent-murmur "7 S" pearl)
5. Peripheral vascular (four-limb BP for coarctation, radio-femoral
   delay, bounding pulse differential)

UI wiring:
- renderSystem() emits APTM diagram card at the top of cv system,
  before scales. Two-column layout: SVG on left, legend on right.
- accentMap/iconMap/labelMap extended with cv = rose accent,
  heart-pulse icon, "Cardiovascular" label
- New sub-tab pill in pe-guide.html
2026-04-22 20:15:08 +02:00
github-actions[bot]
8efc4e9a56 Release v6.15.0 2026-04-22 18:10:04 +00:00
Daniel
83a78fa8cd feat(pe-guide): Respiratory system with synthesized sounds library
Third PE system added. Adolescent respiratory fully fleshed out with
the same teaching-focused depth as neuro: overview, grading scales,
per-component significance + pearls, detailed step methods with HOW
and NORMAL labels, and a watch-for red-flag block.

New respiratorySounds.js uses the Web Audio API to synthesize 8 classic
breath sounds on demand — no network, no audio files, no licensing:
  - Normal vesicular
  - Wheeze (two-partial + vibrato, filtered sawtooth)
  - Stridor (inspiratory, bandpass-filtered sawtooth sweep)
  - Fine crackles (dense brief high-freq noise bursts, late inspiration)
  - Coarse crackles (sparser, longer, lower-freq bursts)
  - Rhonchi (low-pitched warbled sawtooth, expiratory)
  - Pleural friction rub (bandpass noise, biphasic)
  - Expiratory grunting (square-wave short grunts)

Sounds are synthesised approximations intended to teach the pattern
(what makes a wheeze a wheeze vs a stridor). Labelled as such in the UI.
Controls: one play at a time, auto-stop ~3s.

Respiratory-specific grading scales:
  - RR by age (WHO tachypnea cutoffs)
  - Pulse ox (SpO2) with hypoxemia thresholds
  - Silverman–Andersen (neonatal retractions, 0–10)
  - Westley croup severity score

Components in adolescent respiratory:
  1. Inspection (observation-first — RR, pattern, WOB, audible sounds,
     chest shape, colour, clubbing with Schamroth sign)
  2. Palpation (trachea, expansion symmetry, tactile fremitus,
     tenderness, subcutaneous emphysema)
  3. Percussion (technique + systematic zones + cardiac/hepatic
     dullness + diaphragmatic excursion)
  4. Auscultation — normal breath sounds (vesicular, bronchovesicular,
     bronchial) with systematic side-to-side comparison
  5. Auscultation — adventitious sounds with per-sound listen buttons
     linking directly to the sounds library
  6. Special maneuvers — bronchophony, egophony, whispered pectoriloquy

Older age groups (newborn through school-age) will get their own resp
blocks incrementally — v1 focused on adolescent for the quality bar.

UI: new sub-tab pill "Respiratory" with lung icon, sky-blue accent.
renderSystem refactored to use accent/icon maps instead of per-system
if/else — scales to future systems (cardiovascular coming next).
2026-04-22 20:09:54 +02:00
github-actions[bot]
4e1a870fe2 Release v6.14.0 2026-04-22 17:35:12 +00:00
Daniel
33bfc0bfbc feat(pe-guide): teaching-focused redesign — grading scales, pearls, significance
User feedback: the exam steps were too generic (e.g. "Shoulder abduction
— 5/5 bilaterally" never explained what 5/5 means or HOW to test it).
The guide needs to serve as a teaching tool, not just a checkbox list.

Three changes:

1. GRADING SCALES reference card (new). Collapsible panel at the top of
   each system showing the relevant scales:
   - Neuro: MRC strength (0-5), DTR (0-4+), Plantar response
   - MSK: Scoliometer ATR, Beighton hypermobility score
   Each scale shows the grade AND its clinical meaning in a compact
   table. No more orphan "5/5 bilaterally" without definition.

2. Per-component SIGNIFICANCE + PEARL fields (optional). Adolescent
   neuro components enriched with:
   - Significance: one-line clinical relevance (what this component is
     actually for — what pathologies it detects)
   - Teaching pearl: a Hutchison/Bates/Nelson-style tip that helps the
     learner see past the mechanics to the reasoning
   Visually distinct — pearl gets a warm amber accent, significance is
   a crosshair icon under the name.

3. Method strings REWRITTEN for every adolescent strength step. Before:
   "Shoulder abduction — 5/5 bilaterally". After: "Patient abducts both
   arms to 90°. Examiner pushes down on each arm just above the elbow
   while patient resists. Compare sides. — Holds against full resistance
   — MRC 5/5 bilaterally". Same treatment for all 14 strength steps,
   all 8 DTR steps, and tone/pronator-drift.

UI redesign:
- Accent bars on cards (cyan for MSK, purple for neuro) for visual
  anchor
- Numbered step circles instead of "1." prefix
- HOW / NORMAL label badges on each step
- Watch-for block with red left-border for red-flag grouping
- System-level header with icon (bone for MSK, brain for neuro)

Other age groups (newborn through school-age) keep the old data shape
(steps without pearls) — they still render correctly, just without the
pearl/significance blocks. Enriching them is an incremental follow-up.
2026-04-22 19:35:03 +02:00
github-actions[bot]
31507a4f09 Release v6.13.1 2026-04-22 17:30:03 +00:00
Daniel
b37c565cf0 fix(ui): replace native alert/confirm with showConfirm/showToast helpers
Daniel flagged the native browser confirm() dialogs in Extensions as ugly
and incompatible with the app's design. There was also a stray alert()
in calculators.js resus-meds weight validation.

Replaced:
- extensions.js: confirmDelete → showConfirm(..., {confirmText: 'Move to trash'})
- extensions.js: confirmPurge  → showConfirm(..., {danger: true, confirmText: 'Delete permanently'})
- calculators.js:2060 alert() → showToast(..., 'error')

All three helpers (showConfirm, showToast) are already defined as globals
in public/js/app.js. The design already had a modal — I should have used
it from the start.

Audit confirmation: `grep -rnE '\b(alert|confirm|prompt)\s*\(' public/`
now returns only comment references and the showConfirm definition
itself. No native dialogs remain anywhere in the frontend.

Playwright test updated to click the in-app modal's #confirm-modal-ok
and #confirm-modal-cancel buttons instead of intercepting page.on('dialog').
2026-04-22 19:29:53 +02:00
Daniel
8ea79c7f30 test(e2e): shared fixture with pageerror + console-error guards + Extensions CRUD suite
Two additions:

1. e2e/fixtures.js — shared test infrastructure
   - Custom `test` extending @playwright/test with two auto-fixtures on
     every page: page.on('pageerror') and page.on('console') of type
     'error'. Any uncaught JS error fails the test. This is the SSO-
     bug-class safety net: if we'd had this earlier, the silent
     admin.js ReferenceError would have failed CI instead of shipping.
   - `authedPage` fixture — logs in via API, injects session cookie,
     provides pre-authed page ready to drive.
   - `mockAI(page)` helper — intercepts generate-* and transcribe
     endpoints with canned JSON responses. Enables fast deterministic
     CI runs. Opt-out via E2E_USE_REAL_AI=1 to hit real LiteLLM.
   - Console-error allowlist for known noise (favicon 404, lazy-loaded
     Whisper models, etc.).

2. e2e/tests/extensions-crud.spec.js — 11 tests covering the new
   Pagers & Extensions feature end-to-end:
   - empty state, add extension, add pager (correct grouping)
   - edit persists, search by location + by number
   - soft-delete with confirm → moves to trash
   - restore from trash → reappears in active
   - purge from trash → permanent
   - cancel dialog keeps item, cancel form keeps nothing, validation
     on required fields

Known follow-up: the e2e container (pediatric-ai-scribe-e2e) is still
on the pre-entrypoint image from before the OpenBao migration, so it
doesn't have the Extensions routes yet. Rebuilding it needs a small
entrypoint enhancement to honor docker-compose-level env overrides
vs OpenBao-fetched values (e.g. TURNSTILE_SECRET_KEY="" for the e2e
instance). That's separate work — this commit just lays in the tests.
2026-04-22 19:27:05 +02:00
github-actions[bot]
651f799c17 Release v6.13.0 2026-04-22 16:54:37 +00:00
Daniel
d859c8c5a9 feat(extensions): personal Pagers & Extensions directory with soft-delete
New top-level tab positioned after Physical Exam Guide. Per-user
directory of hospital phone extensions and pagers — grouped by location
then type, searchable, soft-deleted.

Data:
- New table user_phone_extensions (id, user_id, location, name, number,
  type CHECK (extension|pager), notes, trashed_at, timestamps).
  Partial indexes on active vs trashed rows for fast filtering.
- Not PHI — hospital internal phone directory. Plaintext.

API (all user-scoped, all params validated):
- GET    /api/extensions?trash=1&q=text  — list active or trash, optional search
- POST   /api/extensions                  — create
- PUT    /api/extensions/:id              — update (requires all three core fields)
- DELETE /api/extensions/:id              — soft-delete (sets trashed_at)
- POST   /api/extensions/:id/restore      — un-trash
- DELETE /api/extensions/:id/purge        — hard-delete (only if trashed)

All :id params parsed + validated (positive integer) before query.
All queries parameterized, every WHERE includes user_id scoping.

UI (public/js/extensions.js + components/extensions.html):
- Search bar with 200ms debounce, server-side LIKE on location/name/number/notes
- Add button expands inline form — location (with datalist of existing
  locations for autocomplete), name/dept, number, type, optional notes
- Each entry renders as a card: big monospace number, dept, type badge,
  edit + delete inline
- Grouped by location → type (Extensions / Pagers subheaders)
- Trash view: toggle shows trashed items with Restore + Purge actions
- Trash count badge on the Trash button updates after every delete/restore
- Delete requires confirm() dialog, then soft-delete (easy to undo)
- Purge from trash requires a second confirm() ("cannot be undone")
- Esc closes the form; form resets between Add and Edit
2026-04-22 18:54:26 +02:00
Daniel
917d4f8115 chore(pe-guide): restore source-citation comment block
Daniel clarified the earlier feedback: not "never cite in code", but
"ask before citing". He confirmed this block should stay.
2026-04-22 18:08:19 +02:00
Daniel
ee4940e0a6 chore(pe-guide): remove source-citation comment block
Daniel prefers no citations embedded in the code. Keeps the data-model
comment which is load-bearing for future edits.
2026-04-22 17:49:36 +02:00
github-actions[bot]
c35b05fc5a Release v6.12.0 2026-04-22 15:15:37 +00:00
Daniel
c13ba04955 feat(pe-guide): step-by-step OSCE checklist, Bates/Nelson/Hutchison sourced
Replaces the generic one-line-per-component format with a step-level
checklist. Each exam component now contains 3–13 discrete steps, each
with its own Normal/Abnormal/Skip toggle and optional abnormal note.
Physician ticks the exam off step-by-step; report generation
summarises at the component level but knows exactly which steps were
performed.

Example — previously the adolescent "Cranial nerves (II–XII)" was a
single row: "How to perform: Full formal adult-pattern exam. Expected:
All cranial nerves intact." That's unhelpful. Now it's 14 discrete
steps: CN I, CN II acuity, CN II fields, CN II fundoscopy, CN II/III
pupils, CN III/IV/VI EOM, CN V sensation V1/V2/V3, CN V motor, CN V
corneal, CN VII forehead/eye-close/smile/puff, CN VIII, CN IX/X, CN
XI, CN XII — each with specific method and expected finding. Same
depth for MSK: scoliosis = 5 discrete steps (standing inspection,
Adam forward-bend, rib-hump check, scoliometer, plumb-line), joint
stability = 8 named tests (Lachman, anterior drawer, varus/valgus,
McMurray, apprehension, Neer/Hawkins, anterior drawer ankle, talar
tilt), Beighton = 5 per-joint measurements, etc.

Sources cited in code header: Bates' Guide 13th ed, Nelson Textbook
22nd ed, Hutchison's Clinical Methods 25th ed, Fenichel Clinical
Pediatric Neurology 8th ed.

Backend route accepts the flat step array (grouped by component on
the server), passes structured text to the AI with methods and
expected findings per step. Prompts updated to summarise at the
component level rather than step-by-step, so output is clinically
readable.

Scope: MSK + Neuro × 6 age groups (newborn, infant, toddler, preschool,
school-age, adolescent). More systems follow the same pattern —
append to PE_DATA.
2026-04-22 17:15:27 +02:00
github-actions[bot]
22dd5cc8f4 Release v6.11.0 2026-04-22 14:52:24 +00:00
Daniel
ac5292b015 feat(pe-guide): Physical Exam Guide tab — OSCE reference + narrative report
New top-level tab (positioned after Catch-Up Schedule) combining two
functions:
1. Study reference — for each (age group, system) shows OSCE-style
   components with technique, expected normal finding, and abnormal-
   feature watch-list.
2. Documentation generator — physician marks each component
   Normal / Abnormal (with free-text detail) / Skip; AI produces a
   two-section report (Technique + Findings), narrative or structured
   list format.

Scope v1: MSK + Neuro × 6 age groups (newborn, infant, toddler,
preschool, school-age, adolescent). More systems can be added to the
embedded PE_DATA in peGuide.js without route changes.

Files:
- src/routes/peGuide.js      — POST /api/generate-pe-narrative (mirrors
                                milestone-narrative pattern: AppRole-level
                                injection guard, clinical audit category,
                                PHI redaction upstream already in place)
- src/utils/prompts.js       — peGuideNarrative + peGuideList prompts,
                                structured two-section output
- public/components/pe-guide.html — demographics bar + sub-pills + cards
- public/js/peGuide.js       — embedded PE_DATA (all clinical content),
                                render + state + AI call
- public/index.html          — tab button, section, script include
- server.js                  — mount route at /api

No schema change. No PHI stored — findings live in memory only, exported
via existing copy/read-aloud/Nextcloud actions.
2026-04-22 16:52:13 +02:00
github-actions[bot]
1bb8918b46 Release v6.10.3 2026-04-22 11:11:43 +00:00
Daniel
749aa23e87 fix(bilirubin-ui): clarify neurotoxicity-risk-factor label + expandable AAP 2022 list
The dropdown was labeled just "Risk Factors" with option "None (lower risk)"
— ambiguous because AAP 2022 uses "risk factors" in two distinct senses:
(a) risk factors for developing hyperbilirubinemia (screening-only, do not
change thresholds) and (b) neurotoxicity risk factors (do change thresholds).
Only (b) belongs on the threshold nomogram, and a clinician glancing at the
form could easily pick wrong.

Changes:
- Label: "Neurotoxicity risk factors"
- Options: "Absent" / "Present (any one qualifies)" — removes the misleading
  "None (lower risk)" phrasing (no-risk curve actually has HIGHER thresholds)
- Expandable details listing the 6 specific AAP 2022 neurotoxicity risk
  factors (isoimmune hemolysis, G6PD, other hemolysis, sepsis, albumin <3.0,
  clinical instability <24h) with explicit note that GA <38w is handled by
  the per-week curve, not by this checkbox — prevents double-counting.

No data changes. Cite: Kemper et al., Pediatrics 2022;150(3):e2022058859, Box 2.
2026-04-22 13:11:34 +02:00
github-actions[bot]
2709595793 Release v6.10.2 2026-04-22 10:41:25 +00:00
Daniel
456101a28a fix(bilirubin): AAP 2022 per-week thresholds, exact match to peditools
The previous tables grouped all ≥38-week infants into one table (using
38w values) and 36-37 into another. AAP 2022 actually has separate
phototherapy curves per completed week for 35, 36, 37, 38, 39, 40+.

Worst real-world impact: a 40-week infant at 72h of life got the 38w
threshold (18.8 mg/dL) instead of the correct 40w threshold
(19.8 mg/dL) — a 1.0 mg/dL error at exactly the clinical decision point.
Borderline infants could be started on phototherapy unnecessarily, or
the reverse (miss a true threshold) depending on the direction.

Replaced the block with 18 distinct per-week tables extracted directly
from peditools.org/bili2022 API:
  - Phototherapy no-risk: 35, 36, 37, 38, 39, 40+ (all differ)
  - Phototherapy with-risk: 35, 36, 37, 38+ (38-41 identical)
  - Exchange (both risk states): 35, 36, 37, 38+ (38-41 identical)

Selection logic updated to map gaNum → correct table. HTML dropdown
label clarified to "completed weeks" with a one-line note that days
don't change the curve per AAP 2022.

Validation: exhaustive roundtrip — 7 GA weeks × 85 hours × 2 risk
profiles = 1190 cases, all match peditools within 0.01 mg/dL (zero
mismatches). See commit message footer.
2026-04-22 12:41:15 +02:00
github-actions[bot]
c4da879336 Release v6.10.1 2026-04-22 03:52:10 +00:00
Daniel
508530eda8 fix(neonatal): replace rounded Fenton 2013 LMS with peditools-validated values
The previous LMS table (L rounded to 2 decimals, ~0 near term) underfit
the skew of Fenton 2013 and drifted ~0.05 z-score units from peditools
and Epic at term. Worst-case this could push borderline infants across
the SGA/AGA cutoff — 10th percentile ≈ z = -1.28, a 0.05-SD drift is
enough to flip the classification.

New LMS: empirically fit against 6 probe weights per week at
peditools.org/fenton2013 (widely-used Fenton 2013 calculator). Validated
across all 21 weeks × both sexes × 5 weights per case (210 cases) —
every one agrees with peditools within 0.01 z-score units, mean
difference 0.002.

Example — 40 5/7 wk male, 3070 g:
  BEFORE: z = -1.38  (matched only one of three external sources)
  AFTER:  z = -1.42  (matches Epic -1.43 and third-source -1.44)
  Peditools at integer 40w: z = -1.10 (exact match with new table at
    integer-week input)

LMS fit RMSE < 0.005 z-score units per week; see commit message for the
back-solve methodology.
2026-04-22 05:52:00 +02:00
github-actions[bot]
887ef04de7 Release v6.10.0 2026-04-22 01:59:19 +00:00
Daniel
42e59fa958 feat(security): OpenBao-backed secret injection via entrypoint
Adds an optional secret-fetch step at container boot. When OPENBAO_ADDR,
OPENBAO_ROLE_ID, and OPENBAO_SECRET_ID are set, the entrypoint
authenticates to OpenBao via AppRole, pulls kv/ped-ai/prod, and exports
each key as a process env var before exec'ing node. When OPENBAO_ADDR
is unset the entrypoint is a no-op — the legacy .env flow continues to
work unchanged (e2e container, local dev, rollback).

Changes:
- docker-entrypoint.sh: new — AppRole login + KV fetch + env inject +
  exec. Fails fast on missing/invalid creds; unsets bootstrap vars
  before launching node so they don't linger in the process env.
- Dockerfile: multi-stage copy of /bin/bao from openbao/openbao:2.5.3
  (multi-arch handled automatically by buildx manifest-list resolution).
  Adds jq for JSON parsing. Wires ENTRYPOINT to the script; CMD
  remains ["node", "server.js"].
- .env.example: documents the three vault-bootstrap variables at the
  top and notes that everything below is vault-sourced when OPENBAO_ADDR
  is set.

Rollout is two-phase for safety: rebuild image with unchanged .env
(proves no regression in legacy mode), then add the three OpenBao vars
and restart to cut over to vault-sourced secrets. Rollback at any point
is blanking OPENBAO_ADDR in .env + restart.
2026-04-22 03:59:10 +02:00
github-actions[bot]
f1802d66f4 Release v6.9.0 2026-04-21 23:01:09 +00:00
Daniel
250646110f fix(security): timing-safe forgot-password + redact log file writer
Two independent PHI-leak hardenings folded together:

1. forgot-password timing oracle
   The hit path previously did SELECT + token gen + UPDATE + SMTP send
   before responding; the miss path returned after the SELECT. An
   attacker could distinguish registered emails by response latency
   (SMTP RTT is hundreds of ms). Response is now sent immediately after
   Turnstile, with the DB and email work fired-and-forgotten in a
   background async block. Hit and miss take identical wall-clock time.

   Also hardened req.body.email to tolerate missing/non-string input
   instead of throwing 500.

2. logger.file redaction
   logger.info/warn/error wrote straight to /app/data/logs/YYYY-MM-DD.log
   without going through redact(). Current callers are metadata-only and
   safe, but any future caller writing logger.error('boom', req.body)
   would silently drop PHI to disk. Route both message and optional data
   through redact() — same helper the audit path already uses. Benign
   startup messages pass through unchanged; SSN/phone/email/DOB patterns
   are tokenised, long note-body-shaped text is truncated.
2026-04-22 01:01:00 +02:00
Daniel
6dc7870a1b feat(security): AES-256-GCM at-rest encryption for encounters and memories
Extends the existing crypto helper (already used for audio backups and the
Nextcloud token) to cover every column that can hold PHI:

- saved_encounters.transcript, .generated_note, .partial_data
- user_memories.content (templates + Dragon-style corrections)
- user_memories.name (auto-derived from original snippet on corrections,
  so effectively PHI)

Reads decrypt transparently. Legacy plaintext rows continue to work —
decryptString passes non-enc1: values through unchanged — so no migration
is required; rows re-encrypt on their next save.

The encounters list query previously used LEFT(transcript, 200) for a
preview. With ciphertext that slice is meaningless, so the route now
fetches the full columns, decrypts in Node, then slices. At 7-day auto-
delete the row count is bounded and the cost is a handful of GCM
decrypts per list call.

user_memories ORDER BY moved from (category, name) to (category, id)
since SQL can no longer order on encrypted names.

Closes the HHS breach-notification safe-harbor gap on at-rest PHI.
2026-04-22 01:01:00 +02:00
github-actions[bot]
cc035e7d8d Release v6.8.0 2026-04-21 20:20:21 +00:00
Daniel
07d7b42efc feat(wellvisit): add Expected Reflexes section to By Visit Age
Adds an age-appropriate reflex reference after the Expected Growth /
Feeding block for each well-visit age. Each entry shows the reflex
name, an expected-status chip (Present / Fading / Integrated /
Up-going / Down-going), and a short clinical note covering how to
elicit it, when it should fade, and what abnormal persistence means.

Covers primitive reflexes for newborn-6mo (rooting, sucking, Moro,
palmar/plantar grasp, tonic neck, stepping, Galant, tongue thrust,
Babinski), the transition at 6-18mo (protective extension, parachute,
plantar-response switch), and adult-pattern DTRs/frontal-release
screening from age 2 through 21.
2026-04-21 22:20:07 +02:00
Daniel
f39f906fa5 fix(admin): restore SSO settings loading on admin panel
loadOidcConfig() was called from the first IIFE in admin.js but declared
in the second IIFE. Function declarations don't cross IIFE boundaries,
so the call threw ReferenceError and left the SSO form empty with
"Disabled" selected — even when SSO was configured and working. Moved
the call into the tabChanged handler of the IIFE where the function
lives.
2026-04-21 22:19:50 +02:00
Daniel
d8504392a5 docs: add Testing section (unit + Playwright e2e) 2026-04-21 02:14:54 +02:00
Daniel
29f37b331e test(e2e): stage 2 — auth-gated pages (11 tabs × 2 viewports, 22 tests)
Adds a second containerized instance of the app with Turnstile + SMTP
disabled so Playwright can log in without a bot challenge.

- docker-compose.e2e.yml: pediatric-ai-scribe-e2e on port 3553. Shares
  postgres + pgdata with main so seeded test users (*@ped-ai.test) persist.
- Test user: e2e-user@ped-ai.test (created once via /api/auth/register
  against the e2e container — SMTP is off so register auto-verifies).
- Tests log in once per worker via /api/auth/login (module-scoped token
  cache) then inject the ped_auth cookie into each test's browser context.
  This avoids the 10-per-15-min login rate-limit.
- Mobile viewport opens the sidebar via #btn-menu-toggle before clicking
  tab buttons (which are hidden behind the hamburger <=768px).

Coverage: encounter, wellvisit, chart, vaxschedule, catchup, learning,
dictation, settings, calculators, faq + landing-page-after-login. Each
test clicks the tab, waits for the lazy component to render (>100 chars),
and asserts a known anchor string is present.

Total suite: 128 tests passing (53 desktop + 53 mobile Bedside/top-calcs +
11 desktop + 11 mobile auth-gated).
2026-04-21 01:48:17 +02:00
Daniel
146ac73da2 test(e2e): run full suite at mobile viewport too (Pixel 5)
Adds a second Playwright project so every calculator test runs at both
Desktop Chrome and Pixel 5 (~375 px). Catches mobile layout regressions
automatically. 106 tests passing (53 × 2 viewports).
2026-04-20 23:21:32 +02:00
github-actions[bot]
ed8948e539 Release v6.7.0 2026-04-20 21:20:01 +00:00
Daniel
fe7b3687ee test(e2e): add 27 Playwright smoke tests for top-row calculator tabs
Covers BP / BMI / BSA / Weight-Based Dosing / Growth / Bilirubin / Vitals /
Resus Meds / GCS / Equipment. Each tab gets: panel-loads test + one
full input→calculate→result flow. Extras for BP (Clear), Dose (max cap),
Growth (sub-pill), Bili (Bhutani sub-pill), GCS (motor change, infant switch),
Equipment (empty re-select hides).

All 53 e2e tests pass (26 Bedside + 27 top-calculators).
2026-04-20 23:19:50 +02:00
Daniel
7e7d469172 fix: scope CORS middleware to /api so static assets aren't rejected
ES module script tags (<script type="module">) always send an Origin
header on fetch, even for same-origin requests. The global cors()
middleware was rejecting /js/bedside/index.js with 500 in the e2e
harness because the container's internal origin
(http://pediatric-ai-scribe:3000) is not in APP_URL/CORS_ORIGINS.

Production was unaffected (real users hit APP_URL, which is allowed),
but the fix is architecturally correct either way: CORS belongs on
the API boundary, not on static file serving. All protected routes
are under /api/*.

Unblocks the bedside smoke suite — now 26/26 green.
2026-04-20 23:19:50 +02:00
Daniel
2742a2a130 feat: C — extract Bedside reference into ES modules
Split the Bedside clinical reference section out of calculators.js
(~1220 lines) into 20 focused ES modules under public/js/bedside/.
Each module owns one clinical topic (cardiac, seizure, sepsis, burns,
etc.) and exports an init() wired up by bedside/index.js. Shared
helpers live in shared.js and also set window._EM for back-compat
with the 4 remaining call sites in calculators.js.

Load order: classic defer calculators.js first, then module script
bedside/index.js. Handlers bind at DOM-ready; runtime _EM lookups
resolve after both are evaluated.

Makes bedside content editable per-section, shrinks calculators.js
from 4111 to 2891 lines, and keeps the existing Playwright smoke
suite (e2e/tests/bedside-smoke.spec.js) as the behavioral contract.
2026-04-20 23:19:50 +02:00
github-actions[bot]
28118c4493 Release v6.6.0 2026-04-20 02:23:33 +00:00
Daniel
f26687df50 feat: B — extract drug data to public/data/drugs.json (schema v1.0)
Moved 43 weight-based drug entries out of calculators.js string literals
into a structured JSON reference. Renderers now iterate JSON → S.drugRow.

Sections extracted (43 drugs):
- anaphylaxis (7), sedation (11), agitation (11), antiemetics (7), seizure (7)

Out of scope: seizure refractory drips (compound strings), NRP, antimicrobial
empiric regimens (already structured), airway/cardiac/tox/trauma/respiratory
/sepsis/burns sections (fine as-is or no drugs).

New:
- public/data/drugs.json — { version, last_reviewed, sections.<key>.drugs[] }
- public/js/drugs-loader.js — fetches JSON on boot, exposes window._DRUGS +
  window._DRUGS_READY Promise. Non-fatal: each calc function has a matching
  *_FALLBACK constant so a 404 on drugs.json doesn't break anything.

Schema:
- dose_mg_per_kg | dose_mg_per_kg_low/high (for ranges) | max_mg | unit |
  route | notes | source | optional special-cases (weight bands, age-dep text)

Added one unit test asserting drugs.json loads + has all 5 sections with
non-empty drugs arrays. 37/37 unit tests + 26/26 Playwright e2e tests pass.
2026-04-20 04:23:24 +02:00
github-actions[bot]
9603a8fcf8 Release v6.5.0 2026-04-20 01:51:30 +00:00
Daniel
abdbaa3507 feat: A1+A2 — Playwright smoke suite + index.html mtime-based caching
Safety net for upcoming refactors:
- 26 Playwright smoke tests via @playwright/test 1.50.0 in an official
  Playwright container (no host Node needed). Covers every Bedside sub-pill,
  the age→weight estimator, dose calculators (seizure/sepsis/anaphylaxis/
  burns/airway), and interactive widgets (lightbox, vent SVG).
- `npm run e2e` wrapper runs tests inside mcr.microsoft.com/playwright:v1.50.0-noble
  on the ped-ai_default Docker network so no host port mapping is needed.
- public/e2e-harness.html + public/js/e2e-bootstrap.js load the calculators
  component without the SPA auth wall (scripts external to satisfy CSP).

Server:
- server.js now re-reads public/index.html on mtime change instead of
  caching at boot. Fixes the "edit HTML, restart container" friction.
- CSP upgradeInsecureRequests disabled in helmet config; Caddy still
  enforces HTTPS at the reverse-proxy layer in production.
2026-04-20 03:51:22 +02:00
github-actions[bot]
30cfc9700b Release v6.4.0 2026-04-20 00:49:55 +00:00
Daniel
ea3a3533e6 feat: Bedside clinical reference module + age→weight estimator + dose-math unit tests
Calculators:
- Bedside tab consolidating emergency protocols (Neonatal+Apgar+NRP, Airway/RSI,
  Cardiac Arrest/PALS, Respiratory, O2 & Ventilation, Status Epilepticus,
  Sepsis & Fever with PECARN/Aronson/Rochester/Step-by-Step, Anaphylaxis,
  Procedural Sedation, Agitation, Antiemetics, Antimicrobials, Burns with
  Lund-Browder body-parts TBSA + Parkland, Toxicology, Trauma).
- Global age→weight estimator at top of Calculators tab (APLS + Best Guess).
- Pressure-time waveform SVG teaching graphic for Ventilation.
- Algorithm image lightbox (fullscreen, Esc/tap-to-close).
- Every weight-based dose shows mg/kg inline for clinician verification.
- Drug tables wrapped in overflow-x:auto for mobile.

Infrastructure:
- Pure dose math extracted to public/js/calc-math.js (dual-export Node+browser).
- 36 unit tests in test/calc-math.test.js via node:test (zero new deps).
- "npm test" added to package.json.
2026-04-20 02:49:42 +02:00
github-actions[bot]
936ecbd113 Release v6.3.1 2026-04-19 19:26:56 +00:00
Daniel
e5f7167b8d fix: auth/API logging to Loki, TTS voice auto-detection, STT ElevenLabs support
- Add logger.audit/access calls to auth route (login, login_failed,
  login_blocked, register, password_changed, 2fa_backup_code_used,
  2fa_backup_codes_regenerated) — these previously only wrote to DB
  via raw SQL, bypassing Loki shipper
- Replace logger.info with logger.apiCall in callAI() so every AI call
  ships to Loki with model, tokens, cost, duration
- Add device identifier (parsed user agent) to audit and access logs
- Fix TTS voice/model provider mismatch: auto-detect Vertex voices
  (Puck, Charon, Kore, etc.) and ElevenLabs voice IDs, override model
  to match provider regardless of what model was previously set
- Fix TTS discovery: model IDs saved to tts.voice are detected and
  redirected to tts.model (regex for openai-tts, elevenlabs, vertex-tts)
- Fix STT transcription route: add scribe/elevenlabs/transcri to the
  isTranscriptionModel regex so ElevenLabs Scribe uses /audio/transcriptions
  endpoint instead of chat completions
- Remove OpenObserve/SigNoz code from logger (reverted to Loki-only)
2026-04-19 21:26:49 +02:00
github-actions[bot]
b09276faf5 Release v6.3.0 2026-04-19 00:17:21 +00:00
Daniel
2f6e5a7d8f feat: neonatal calculator, DOCX/PPTX/ODT/EPUB support, gateway-agnostic URL helper, TTS/STT fixes
- Add neonatal assessment calculator: GA classification (extremely preterm through
  post term), weight-for-GA percentile (AGA/SGA/LGA) using Fenton 2013 LMS data,
  birth weight category (ELBW/VLBW/LBW/normal/macrosomia)
- Add DOCX support via mammoth, PPTX/ODT/EPUB via jszip in Learning Hub content
  generator file upload
- Add gatewayUrl() helper for consistent API URL construction — handles
  LITELLM_API_BASE with or without /v1 suffix, works with any OpenAI-compatible
  gateway (LiteLLM, Bifrost, etc.)
- Fix TTS model/voice separation: discovery now tags items as MODEL or VOICE,
  auto-detects provider from voice name (Vertex, ElevenLabs, OpenAI)
- Fix STT discovery to include ElevenLabs Scribe and Chirp models
- Fix TTS discovery to include ElevenLabs and Vertex voices alongside models
- Fix admin model test to bypass allowlist check (skipAllowlistCheck) so
  discovered models can be tested before adding
- Fix Nextcloud token decryption in learningAI.js WebDAV browse and file import
- Fix admin embedding test to show DB model name instead of hardcoded default
- Fix admin STT test to use correct endpoint for Whisper models
- Add AI gateway migration guide to configuration docs
- Add Grafana dashboard JSON for Loki log visualization
2026-04-19 02:17:06 +02:00
Daniel
857ed341f5 docs: rewrite architecture, authentication, configuration, deployment, ai-providers, speech, database, learning-hub, migrations, developer-guide for public audience
- Drop first/second-person voice; reference-style prose throughout
- Remove stale information; align with current code (argon2id primary, hybrid cookie/Bearer auth, sliding 24h idle, AES-256-GCM PHI at rest, backup codes, node-pg-migrate, collation-drift guard, multi-arch Docker, auto-version pipeline)
- Preserve all technical accuracy and code examples
- Remove any remaining references to separate PedsHub Quiz app
- Keep consistent tone across files (tables + code blocks, imperatives where needed)
- api-reference.md and developer-guide.md route tables expanded to reflect current routes (billing, sessions)
2026-04-15 00:26:38 +02:00
Daniel
957ba531bc docs: clarify com.pedshub.scribe is the Android applicationId, not a quiz-app ref 2026-04-15 00:13:17 +02:00
github-actions[bot]
895caa2093 Release v6.2.1 2026-04-14 22:10:45 +00:00
Daniel
231a86509f fix: verify auto-version → android + docker pipeline end-to-end 2026-04-15 00:10:35 +02:00
Daniel
7ad0c84789 ci: multi-arch Docker via native runners + Node 24 opt-in + PAT for tag-trigger chain
docker-publish.yml:
  - Rewrote as matrix build + manifest merge.
  - amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm (free for public
    repos). No QEMU — argon2 and every other native dep compile on
    their target CPU, no more SIGILL / exit 132.
  - Per-arch GHA cache scopes so builds don't thrash each other.
  - Final step merges both digests under one tag (vX.Y.Z + latest),
    publishing a real multi-arch manifest. `docker pull` from either
    arch gets the right variant automatically.

auto-version.yml, version-bump.yml:
  - Checkout now uses `secrets.RELEASE_PAT || secrets.GITHUB_TOKEN`.
    With RELEASE_PAT set, the tag push this workflow does DOES
    trigger downstream (android-release, docker-publish). Without
    it, falls back to GITHUB_TOKEN (no downstream trigger, what we
    have today).

All workflows (auto-version, version-bump, android-release,
docker-publish):
  - Added FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' so actions
    still on Node 20 runtime (checkout/cache/setup-*) opt in to
    Node 24 early. GitHub makes Node 24 default 2026-06-02 and
    removes Node 20 2026-09-16.

To finish the chain (one-time user step): create a fine-grained
PAT with "Contents: Read and write" on this repo and add as
RELEASE_PAT secret. After that `feat:` / `fix:` commits auto-tag
AND auto-build with zero manual intervention.
2026-04-15 00:05:34 +02:00
Daniel
fbc7890378 docs: strip PedsHub Quiz refs from mobile-build + terser CONTRIBUTING
mobile-build.md:
  - Removed "PedsHub Quiz" sections. That app lives in a separate
    repo (quiz/mobile/) and has its own build pipeline. Docs here
    are PedScribe-only now.
  - Reorganized around CI as the primary flow, local build as
    fallback. Added explicit secret names, JDK requirement, single-
    quote-password caveat, QEMU/argon2 note.
  - File-map section at the end so the native sources are
    discoverable without grepping.

CONTRIBUTING.md:
  - Cut the narrative prose. Dev-facing tables + single-line
    commands only. Decision-tree removed (the table suffices).
  - Release pipeline and mobile build link out rather than
    duplicating content.
2026-04-14 23:54:41 +02:00
github-actions[bot]
9085bb6bb6 Release v6.2.0 2026-04-14 21:51:17 +00:00
Daniel
f5a10419de docs: add CONTRIBUTING.md + .gitmessage template for conventional commits
Drops a cheat sheet (CONTRIBUTING.md) in repo root so anyone — you,
future maintainers — has the full commit-prefix table one glance
away. Covers which prefixes trigger a release and which don't.

Also adds .gitmessage that you can optionally wire into git as the
default commit template:

  git config --local commit.template .gitmessage

Opens the cheat sheet in your editor every time you `git commit`
without -m. Remove it with `git config --local --unset commit.template`.

This commit uses `docs:` prefix so it does NOT trigger a release —
proving the auto-version workflow's filter works.
2026-04-14 23:51:09 +02:00
Daniel
9bfece8532 feat: auto-version workflow — tags managed by commit messages
Adds .github/workflows/auto-version.yml that fires on every push to
main, parses commit messages since the last semver tag, and decides
whether to cut a new release:

  feat:      → minor bump   (new feature, backward-compatible)
  fix:       → patch bump   (bug fix)
  feat!:     → major bump   (breaking change)
  BREAKING CHANGE in body → major bump
  docs/chore/refactor/style/test/ci → no release

If any commit since the last tag matches feat/fix/BREAKING, the
workflow bumps versions across package.json, mobile/package.json,
mobile/android/app/build.gradle, commits the change as
"Release vX.Y.Z", tags it, and pushes. The tag push then fires the
existing android-release and docker-publish workflows.

You no longer need to remember "what version am I on?" — just commit
with a conventional-commits prefix and push. Docs-only or refactor
commits don't create releases. Add [skip ci] to any commit message
to skip this workflow for that commit.
2026-04-14 23:47:37 +02:00
Daniel
7b39c6c615 CI: fix docker multi-arch crash + add one-click version-bump workflow
docker-publish.yml:
  - Dropped linux/arm64 from the platforms matrix. The amd64 GitHub-
    hosted runner builds arm64 under QEMU emulation, which fails at
    native argon2 compile with SIGILL (exit 132). Your production
    box is x86, so arm64 isn't needed. Add it back with a native
    ARM runner the day you deploy to ARM hardware.

version-bump.yml (new):
  - Manual Actions trigger. Click "Run workflow" → pick patch / minor /
    major (or type a custom X.Y.Z). The workflow computes the next
    semver from the current package.json version, updates all three
    version sites (package.json, mobile/package.json, Android
    versionName + versionCode), commits "Release vX.Y.Z", tags it,
    and pushes. The tag push then fires android-release.yml and
    docker-publish.yml automatically — APK + Docker image published
    with no local commands required.

Typical flow now:
  Actions → "Version bump & release" → Run workflow → patch
    ↓
  Bump + tag in ~5 s
    ↓
  Parallel: android APK build (~2 m), docker image push (~4 m)
    ↓
  Both assets show up on the new release; Obtanium + docker-hub
  subscribers see the update automatically.
2026-04-14 23:44:47 +02:00
Daniel
d0009e94ed release.sh: drop node dependency, use sed for version bump 2026-04-14 23:40:38 +02:00
Daniel
b485eec828 Release v6.1.1 2026-04-14 23:40:10 +02:00
Daniel
63b8110993 Add mobile/.gitignore (should have been in prior cleanup commit) 2026-04-14 23:39:26 +02:00
Daniel
ef341671e2 Untrack Capacitor-generated files + node_modules in mobile/
The mobile/ wrapper had 1700+ node_modules files tracked, plus the
Capacitor-regenerated artifacts that get rewritten on every
`npx cap sync android` (capacitor.build.gradle, capacitor.config.json,
capacitor.plugins.json, capacitor.settings.gradle, the cordova-android-
plugins subtree). Every local dev or CI sync caused noisy drift that
blocked scripts/release.sh from running.

Added mobile/.gitignore covering node_modules, cap-sync outputs,
Android build outputs, .jks/.apk/.aab files, and .DS_Store.
Kept package-lock.json tracked for reproducible npm install.

No logic changes — only stopped tracking files that are always
regenerated.
2026-04-14 23:35:23 +02:00
Daniel
0471aee5a5 CI: GitHub Actions workflow to auto-build signed Android APK on tag push
On every v*.*.* tag push the workflow:
  1. Checks out the repo
  2. Sets up JDK 17 + Node 20 + Android SDK (cached between runs)
  3. Runs npm install + npx cap sync android in mobile/
  4. Restores the signing keystore from ANDROID_KEYSTORE_BASE64 secret
  5. Builds a signed release APK via gradle
  6. Renames to pedscribe-X.Y.Z.apk
  7. Creates/updates the matching GitHub release with the APK attached
     and make_latest=true so the /releases/latest URL always points to
     the newest build (Obtanium and the login-page link pick it up
     automatically)

Required repo secrets (set via gh secret set ... or the GitHub UI):
  ANDROID_KEYSTORE_BASE64   base64 -w0 of the .jks file
  ANDROID_KEYSTORE_PASSWORD keystore password
  ANDROID_KEY_ALIAS         key alias (pedscribe)
  ANDROID_KEY_PASSWORD      key password (same as keystore in our setup)

Typical release flow after this lands:
  scripts/release.sh 6.1.1 --push      (laptop, 5 sec)
  ── Actions builds APK in ~8-10 min ──
  ── Release updates automatically with signed APK ──
  ── Obtanium clients notice on next poll ──
2026-04-14 23:24:17 +02:00
Daniel
73398e91ed Version alignment + release script — single source of truth
Aligns every version string in the repo to 6.1.0:
  - package.json: 6.0.0 → 6.1.0
  - mobile/package.json: 1.0.0 → 6.1.0
  - mobile/android/app/build.gradle: versionCode 1 → 610,
      versionName "1.0" → "6.1.0"
  - server.js: hardcoded "v6.0" → reads root package.json at boot
  - /api/health/detailed now reports APP_VERSION from package.json

Adds scripts/release.sh — a one-command bump:
  scripts/release.sh 6.1.1                # local bump + tag
  scripts/release.sh 6.1.1 --push         # + git push
  scripts/release.sh 6.1.1 --push --gh    # + GitHub release (uploads
                                            APK if already built)

Updates all three version sites, commits "Release v6.1.1",
creates annotated tag, optionally pushes and opens a release.
versionCode encoded as MAJ*100000 + MIN*1000 + PATCH so patch
updates always increment monotonically.
2026-04-14 23:18:47 +02:00
Daniel
77bd7c5b1c Hide APK download link on the native Android app
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
The "Download Android app (APK)" link on the login page is pointless
when the user is already inside the Capacitor app. Wrapped the link
in id="apk-download-link" and added a native-app-only hide pass in
auth.js that runs against a short array of web-only element IDs.

Add more entries to that array as other web-only UI appears, so the
mobile wrapper can diverge cleanly from the web UI without branching
the HTML.
2026-04-14 22:47:59 +02:00
Daniel
b4704944cb Replace all 'Johns Hopkins Kids Kard' citations with 'Harriet Lane Handbook' 2026-04-14 22:41:10 +02:00
Daniel
43ee0e7ab5 Bhutani: swap eyeballed values for pre-digitized table from codingace.net
Replaces my best-effort image readings with the pre-digitized
JavaScript data arrays extracted from codingace.net's open
Bhutani calculator (their arrays were embedded in the page
source, apparently digitized from the original Figure 2 at
6-hour granularity through 72 h).

Cross-checked against the AAP 2004 CPG reproduction of the same
Bhutani chart (Southern Health Manitoba clinical policy PDF).
Classifications at several spot-check points (24/36/41/72 h at
varying TSB) match expected zones.

User's reference case (41 h of life, TSB 9.7 mg/dL):
  p95 ≈ 13.6, p75 ≈ 11.4, p40 ≈ 9.1
  → Low-Intermediate Zone  ✓  (matches clinician expectation)

Data source now properly cited in both the in-code block comment
and the on-card footer text. Tool still documents that AAP 2022
is the correct tab for phototherapy decisions.
2026-04-14 13:05:54 +02:00
Daniel
6db52eeec4 Bhutani: replace unsourced values with image-read Stanford nomogram
The previous bhutaniZones table was introduced in the initial
calculator commit (61cf096, 2026-04-09) without any source citation
and was systematically ~0.5-1 mg/dL below the published Bhutani 1999
curves — borderline patients got pushed into the next-higher zone.

New values read directly from the Stanford Medicine Newborn Nursery
reproduction of Bhutani 1999 Figure 2:
  https://med.stanford.edu/newborns/professional-education/jaundice-and-phototherapy/bhutani-nomogram.html

Uncertainty: ±0.3 mg/dL (values eyeballed from a 556 px rendered
graph, not a published table). This is called out explicitly in
both the in-code comment and the Bhutani tab footer, which also
points clinicians to the AAP 2022 tab for actual phototherapy
decisions.

Spot-check: 41h of life, TSB 9.7 mg/dL
  Before: p75=9.33 → High-Intermediate Zone (wrong)
  After:  p75=10.1 → 9.7 below p75, above p40 → Low-Intermediate ✓

No interpolation or decision logic changed — only the lookup data
and its citation.
2026-04-14 13:02:26 +02:00
Daniel
a514405261 Vitals: simplify source to 'Harriet Lane Handbook' 2026-04-14 12:48:06 +02:00
Daniel
ef01eca8ca Citations: move Harriet Lane label to Vitals; restore honest Bhutani cite
Vitals — updated source attribution to "Harriet Lane — Johns Hopkins
Children's Center Kids Kard" (was just Kids Kard). Both the card
subtitle and the intro line.

Bhutani — reverted my incorrect "Harriet Lane" citation (the values
in our table were never transcribed from Harriet Lane). Restored the
original attribution to the 1999 paper. Data itself is unchanged from
the pre-session state; treat it as unverified pending a clinician-
supplied source.
2026-04-14 12:43:17 +02:00
Daniel
b27c79e8ae Bhutani tab: cite Harriet Lane (Johns Hopkins Kids Kard) as source 2026-04-14 12:40:04 +02:00
Daniel
868ed53bbc Revert "Fix Bhutani nomogram values — correct percentile tables"
This reverts commit 48e0749435823376efa581e33db34f09d3123b52.
2026-04-14 12:35:36 +02:00
Daniel
d1b6da4291 Fix Bhutani nomogram values — correct percentile tables
Previous table was ~0.5-1.0 mg/dL below the published Bhutani 1999
nomogram at every reference point, which pushed borderline patients
into the next-higher zone. Coarse 12-hour granularity made the
interpolation error worse between reference points.

Corrected to the PediTools-vetted values (same source we use for
AAP 2022) with 6-hour granularity. Source: Bhutani VK et al.,
Pediatrics 1999;103(1):6-14.

Example: 41h of life, TSB 9.7 mg/dL
  Before: p75=9.33 → TSB above p75 → "High-Intermediate Zone" (wrong)
  After:  p75=10.25 → TSB below p75, above p40 → "Low-Intermediate Zone"
          (matches the published nomogram)

No code-path changes — only the lookup data.
2026-04-14 12:32:07 +02:00
Daniel
290724c883 Revert "Bilirubin chart: BiliTool/PediTools-style visual polish (no data changes)"
This reverts commit e6d90f4ba686d73aaf3958e68b08bb2d1c4026de.
2026-04-14 12:27:14 +02:00
Daniel
c11cfe45b7 Revert "Bili chart: shorter labels + wider right padding to stop clipping"
This reverts commit 110924807c412cd47c56eccf3ce9ee4053f8a7a4.
2026-04-14 12:27:14 +02:00
Daniel
1c106e71db Bili chart: shorter labels + wider right padding to stop clipping
Replaced "Phototherapy" with the clinical abbreviation "Photo Tx"
(fits in the right margin without cut-off). Exchange label stays.
Bumped the layout right-padding from 40 px to 88 px so even the
longest label ("95th (High-Risk)" on the Bhutani chart) prints
fully inside the canvas on narrow viewports.
2026-04-14 12:21:42 +02:00
Daniel
e8a2283fae Bilirubin chart: BiliTool/PediTools-style visual polish (no data changes)
Pure visual improvements to renderBiliChart. Interpolation, lookup
tables, and threshold math are all untouched.

AAP chart now has three-zone shading matching BiliTool's convention:
  - Faint green tint below the phototherapy curve (safe zone)
  - Amber band between phototherapy and exchange (treatment zone)
  - Unshaded above the dashed crimson exchange line (danger)
  Phototherapy line: solid orange 2.4 px; Exchange: dashed crimson.

renderBiliChart common improvements:
  - Right-edge label on each threshold line ("Phototherapy", "Exchange",
    or "95th (High-Risk)" for Bhutani) with white halo — identifiable
    without a legend, like PediTools
  - Legend removed (replaced by the inline labels)
  - X-axis auto-ranges to fit the actual data with tick every 12 h
  - Y-axis tick every 5 mg/dL for clean BiliTool-style gridlines
  - 40 px right padding so labels don't clip
  - Patient dot shrunk from r=8 to r=5 with a 1.8 px white ring,
    redrawn on top of every label + its TSB value printed next to it
  - Bhutani chart inherits all the same improvements without changing
    its own zone/fill setup
2026-04-14 12:17:32 +02:00
Daniel
7a7fd5b4eb Growth charts: back to 7 percentile lines (drop 5th and 95th)
Reduces label crowding at the chart extremes. Clinical meaning
preserved: 3rd and 97th remain as the abnormal-threshold dashed
outermost lines, 10/25/50/75/90 give the mid trend. The bidirectional
label spread, leader lines, and dot-on-top rendering from the prior
pass all still apply to the 7-line layout.

Fills updated for new indexes (3↔97, 10↔90, 25↔75).
2026-04-14 07:16:38 +02:00
Daniel
02f4bea747 Growth charts: bidirectional label spread + leaders + dot-on-top
Labels at the top (97/95/90) crowd just as much as the bottom
trio — prior forward-only pass only spread the bottom. Now:
  - Forward pass pushes items DOWN when crowded from above
  - Backward pass pulls items UP when still crowded from below
  - Min gap bumped 13 → 15 px for breathing room
  - Labels clamped to stay inside chart top/bottom
  - Thin leader line drawn from native curve position to the label
    when the two diverge by more than 2 px, so you can still see
    which line a nudged label belongs to
  - Patient dot redrawn on top of all labels at the very end of
    the plugin so a top-region label can never cover the dot
    (Chart.js's afterDatasetsDraw fires after dataset rendering,
    so the native dot was being painted over).
2026-04-14 07:10:20 +02:00
Daniel
aa1261da4e Growth charts: fix label ordering + shrink patient dot
Label plugin rewritten:
  - Collect all percentile-line labels with their native screen Y
  - Sort top-to-bottom (97th ... 3rd) so order is always correct
  - Walk the sorted list and enforce 13px min vertical gap by
    pushing down (never reordering)
  - Second-pass pull-back if the stack would clip off the chart
    bottom
  Prior logic could nudge "10th" past "3rd" because it moved labels
  independently without respecting their natural screen order.

Patient dot: radius 8 → 5 (hover 11 → 7). The 8px ring was
dominating the chart; 5px is still clearly visible with the 1.8px
white border but no longer obscures adjacent curves.
2026-04-14 07:06:49 +02:00
Daniel
1a176f082a Growth charts: restore full 9-percentile Epic/CDC line set
Reverted from the 7-line reduced set back to the full clinical set:
3rd, 5th, 10th, 25th, 50th, 75th, 90th, 95th, 97th — matching what
Epic and the printed AAP/CDC/WHO charts display.

Kept the mobile-readability improvements from the prior pass:
  - Each percentile is a distinct hue (red / deep-orange / orange /
    amber / green / blue / violet / purple / pink)
  - Outer 3rd + 97th are dashed (abnormal-threshold convention); 5th
    and 95th use a tighter dash; rest solid
  - 50th remains bold green to anchor the center
  - Label plugin's white halo + vertical nudge keeps 3/5/10 and
    90/95/97 label stacks legible
  - Patient dot still on top, white-ringed

Fill bands updated for 9 indexes (3↔97, 5↔95, 10↔90, 25↔75).
2026-04-14 07:02:46 +02:00
Daniel
2cb99b263f Growth charts: 7 distinct-color percentile lines + readable mobile labels
Reduced reference curves from 9 to 7 (dropped 5th and 95th — they
crowd the 3rd/10th and 90th/97th labels on small screens without
adding clinical value; 3rd and 97th are the US/WHO standard
abnormal thresholds).

Each percentile now gets a distinct hue:
  3rd  red     50th green (bold)    90th violet
  10th orange  75th blue            97th pink
  25th amber                        (3rd/97th dashed)

Label plugin improvements:
  - Bolder 11px font (was 10px)
  - White halo stroke behind text so labels stay legible when the
    line they sit on is also colored
  - Vertical nudge when two labels would overlap — keeps adjacent
    percentiles readable on mobile aspect ratios
  - Solid fill color (strips alpha from borderColor)

Patient dot:
  - Moved to order: -1 (drawn on top of everything, including labels
    and fill bands)
  - White 2.5px border ring so it's visible even when it lands
    exactly on a colored curve
  - Slightly larger hover radius (11 → was 10)
2026-04-14 07:01:53 +02:00
Daniel
de0562060b Growth charts: label each percentile curve (3rd, 50th, 97th, …)
Adds a Chart.js plugin that draws the percentile label at the right
end of each reference line, matching the convention on printed
WHO/CDC growth charts. Lets clinicians identify lines at a glance
without using the legend. Canvas gets 30px right-padding so the
labels don't get clipped.
2026-04-14 06:55:12 +02:00
Daniel
e32e9977f5 Cache-busting version stamps + client-side encounter version tracking
1. Build-ID cache busting (server.js):
   - Compute a BUILD_ID at boot: git HEAD short hash if available,
     else /app/BUILD_ID file, else random-on-boot.
   - On first request for /, rewrite every local /js/*.js and
     /css/*.css reference in index.html to include ?v=BUILD_ID.
     Cached once at startup so subsequent renders are free.
   - X-Build-Id response header + GET /api/build expose it for
     debugging.
   - Eliminates the "works after hard-refresh" class of bugs: every
     deploy gets a new build ID, so browsers fetch fresh JS/CSS on
     the very next page load.

2. Optimistic encounter locking wired into the client
   (public/js/encounters.js):
   - On resumeEncounter(): stash enc.version into
     window._encounterVersions[id]
   - On saveEncounter(): send expected_version in the POST body
     when we have one.
   - Server returns 409 if another tab/device wrote first → user
     sees "Someone else edited this encounter. Reload to see the
     latest version." instead of silently clobbering the prior save.
   - On success, remember the new server-assigned version for the
     next save.
2026-04-14 05:40:42 +02:00
Daniel
2f3e608c88 Fix: local-auth users lose Password/2FA/Sessions after refresh
Previous check was strict (canLocalAuth !== true → hide). On a
transient /me hiccup or when the boot cache lagged, a legit local
user saw empty Settings with none of the sections they should see.

Inverted the predicate: hide only when canLocalAuth === false
(explicit SSO-only signal from the server). Undefined/missing now
defaults to show — local-auth users never lose their own UI.
Still hides correctly for the documented SSO-only case because the
/me endpoint sets the flag to false explicitly for those users.
2026-04-14 05:32:30 +02:00
Daniel
5b0c296a88 Prompt-injection wrap: remaining AI routes
Applied the <UNTRUSTED_*> delimiter + INJECTION_GUARD pattern to:
  - src/routes/sickVisit.js  (chief complaint, transcript, dictation,
                               ROS, physical exam, diagnoses, style hints)
  - src/routes/wellVisit.js  (SSHADESS answers + full well-visit context)
  - src/routes/chartReview.js (PMH + all visit content + labs)
  - src/routes/hospitalCourse.js (all notes/H&P/ED + clarification
                                   & update endpoints)
  - src/routes/milestones.js (narrative + summary)

Each wraps patient-derived text in <UNTRUSTED_*>…</UNTRUSTED_*>
tags and appends the INJECTION_GUARD system instruction that tells
the model to treat wrapped content strictly as data. Operator-
supplied `additionalInstructions` stays unwrapped (trusted).
2026-04-14 05:31:54 +02:00
Daniel
7a06a4aa63 Batch of security + scale fixes
Age parser (src/routes/billing.js):
  - Now sums year + month + week + day matches so "4 yr 11 mo"
    (59 months) correctly maps to the 5-11y billing bracket instead
    of being billed as 1-4y. Added bounds sanity check.

Graceful SIGTERM shutdown (server.js):
  - Closes the HTTP listener first, then drains batched audit queues,
    then ends the Postgres pool. 9-second hard deadline to beat
    Docker's 10-second SIGKILL. Previously an in-flight note save
    during a container restart could truncate the write.

Explicit LLM fallback opt-in (src/utils/ai.js):
  - The OpenRouter / LiteLLM silent fallback now requires admin
    setting `ai.allow_model_fallback = true` (default: false). If
    primary fails and fallback is disabled, the error is surfaced
    to the caller. Prevents silent spillover from a BAA-covered
    primary to a non-covered fallback.

Prompt injection delimiters (src/utils/promptSafe.js):
  - Wraps user transcripts, dictations, refine-instructions, and
    pasted documents in <UNTRUSTED_*>...</UNTRUSTED_*> tags and
    appends an explicit system instruction telling the model to
    treat the wrapped content as data rather than commands.
  - Applied to soap.js, hpi.js, refine.js. Extend to other AI
    routes incrementally.

Cross-tab logout sync (public/js/authFetch.js, auth.js):
  - BroadcastChannel('pedscribe-auth') — logout in one tab posts
    a message; all sibling tabs clear state and reload, dropping
    any PHI-containing UI immediately.

Backup code race-free consumption (src/routes/auth.js):
  - tryConsumeBackupCode() now uses a Postgres transaction with
    SELECT ... FOR UPDATE so concurrent login attempts using the
    same code serialize. First wins, second sees the already-
    shortened array.

Optimistic encounter locking (migrations/...add-encounter-version):
  - saved_encounters.version INTEGER NOT NULL DEFAULT 1
  - POST /api/encounters/saved accepts an expected_version and
    rejects with 409 if the row has advanced. Falls back to
    last-write-wins if the client doesn't pass one (backward compat).

Audit log batching (src/utils/auditQueue.js):
  - Audit / api_log / access_log writes are buffered in memory and
    flushed every 1s or every 50 entries via one multi-row INSERT.
    Under load this reduces DB pressure by ~50x. On SIGTERM the
    shutdown path drains the queue before exiting.
2026-04-14 05:24:40 +02:00
Daniel
8db25f39be Enforce server-side LLM model whitelist + scope idle timeout to writes
Two findings from review:

1. callAI() previously accepted any model string from the client.
   POST /api/hpi with { model: "openai/o1" } would call the reasoning
   model regardless of whether the operator enabled it. Added
   getAllowedModelIds() in src/utils/models.js (60s TTL DB-backed
   cache) and a guard at the top of callAI() that rejects with
   "model_not_permitted" when the requested ID isn't in the active
   roster. No model supplied → silent fallback to DEFAULT_MODEL.

2. Middleware was updating user_sessions.last_activity on every
   request, including GETs. Client-side polling (/api/auth/me
   heartbeats, dashboard refreshes, log tail calls) kept sessions
   alive indefinitely, defeating the 24h sliding idle policy. Now
   only POST/PUT/DELETE/PATCH count as "user activity". GETs are
   read-only and often automated — they no longer extend the
   session. Idle enforcement still runs on every method, so a
   24h-idle user still gets kicked on their next GET.
2026-04-14 05:15:55 +02:00
Daniel
a2b1b262cb Fix: revoked sessions now actually log the other device out
The server-side revoke was always working — it deletes user_sessions
rows, and middleware correctly returned 401 on the revoked device's
next /api/* request. The bug was entirely client-side: individual
fetch handlers swallowed the 401 (rendering "no sessions found" or
empty data) and nothing redirected to the login screen. So the
revoked device looked like it stayed signed in.

Added public/js/authFetch.js: a global fetch interceptor that
watches every /api/* response. On 401 from a non-auth endpoint
(i.e. not /login, /register, /logout, /me, etc.), it clears any
cached token/user state and reloads the page. The reload's boot
flow lands on /api/auth/me → 401 → login screen as usual.

Guarded against false positives: only triggers when the app believes
the user is currently logged in (AUTH_TOKEN set or main-app visible)
so a pre-login 401 doesn't accidentally flash the screen.

Loaded before auth.js in index.html.
2026-04-14 05:10:16 +02:00
Daniel
fcf11ec326 Hide Active Sessions for SSO-only users
Follows the same pattern as Change Password and 2FA sections —
hidden by default in the HTML, revealed only when canLocalAuth=true.

Why: revoke technically deletes the PedScribe session row and clears
the cookie on that device, but the SSO user can re-auth instantly
because their IdP session is still live. Surfacing a "revoke" button
that the IdP will immediately undo is misleading. SSO users now see
only the SSO-relevant sections of Settings.
2026-04-14 05:07:07 +02:00
Daniel
64ac4ff6bb Add node-pg-migrate for versioned schema changes + better mobile UA labels
Infrastructure only — no existing data or tables modified.

  src/db/migrate.js           — programmatic runner, fires at boot after
                                 the existing idempotent initDatabase()
  migrations/1744600000000...  — intentionally empty example, documents
                                 the file shape. Registered in the new
                                 pgmigrations tracking table so it won't
                                 rerun.
  .node-pg-migraterc.json     — CLI config (migrations-dir, utc naming)
  docs/migrations.md          — workflow + conventions
  package.json                — migrate:up/down/new/status npm scripts
                                 (status is a direct pgmigrations query
                                 since node-pg-migrate v7 lacks a status
                                 subcommand)

src/utils/sessions.js:
  - parseUserAgent now recognizes the Capacitor wrapper (UA suffix
    "PedScribe-Android" / "PedScribe-iOS") and labels sessions
    "PedScribe (Android)" instead of "Chrome on Android".

Going forward: schema changes go in /migrations as versioned files
with up() + down(); the inline init in database.js is the implicit
baseline for everything already in production.
2026-04-14 05:06:19 +02:00
Daniel
040218a7bf Fix critical auth bug: set httpOnly cookie on local login/register
After the hybrid auth migration, web users log in but the
setAuthCookie() helper was never actually called in /login or
/register — only in the OIDC callback. Result: local sign-in worked
until the first page reload, then the user appeared logged out. The
Settings page's Active Sessions list came up empty because
/api/sessions received no auth.

Added setAuthCookie(res, token) calls on successful:
  - /register (auto-verified first admin path)
  - /login (after TOTP / backup code verification)

Mobile is unaffected — it uses Bearer from Keychain and always has.
2026-04-14 04:55:12 +02:00
Daniel
ed015f3774 Fix local-auth sections not showing for normal users + backup-code modal signature
settings load2FAStatus():
  - Explicit credentials: 'same-origin' on the /me fetch (was relying
    on fetch defaults, which can behave oddly in some browsers/edges)
  - Fall back to window.CURRENT_USER (cached at login) if /me fails,
    so local-auth users still see their password/2FA sections after
    a transient error. Keeps cache in sync on each successful fetch.

enterApp():
  - Cache the logged-in user object on window.CURRENT_USER so modules
    that need the canLocalAuth flag don't have to re-fetch /me.

2FA regenerate modal:
  - Previous call passed a wrong-shape options object to showConfirm.
    Updated to the correct (message, callback, opts) signature with
    input:true, inputType:'password', placeholder, required.

OIDC email_verified check:
  - Accept boolean true or string 'true' for robustness. Some IdPs
    serialize ID-token booleans as strings.
2026-04-14 04:43:07 +02:00
Daniel
b6753d5bc9 Hide change-password + 2FA by default, show only when canLocalAuth=true
Sections were briefly visible for SSO-only users before load2FAStatus
resolved and hid them. Flipped the default: both sections now carry
style="display:none" in the HTML and are revealed only when the /me
fetch confirms the user has a real password hash.

SSO-only users never see the sections, even for a flash.
2026-04-14 04:38:28 +02:00
Daniel
f9732f25d0 Server-side SSO/local-auth enforcement + OIDC account-link hardening
Endpoint guards (defense-in-depth over hidden UI):
  - POST /api/auth/change-password: 400 with SSO-aware message if
    the caller's stored password is not a real bcrypt/argon2 hash.
    Prior behaviour was to fail at passwords.verify() with an
    ambiguous "current password is incorrect".
  - POST /api/auth/setup-2fa: 400 with same SSO-aware message for
    SSO-only accounts. Prior behaviour allowed TOTP setup on an
    account where it could never actually trigger (user never logs
    in locally).

OIDC account-link safety (src/routes/oidc.js):
  - Auto-link to an existing local account now requires the IdP to
    assert email_verified=true in the ID token (or userinfo). If
    absent/false, the callback redirects with ?error=email_unverified.
    Prevents an attacker at a misconfigured IdP from taking over a
    local account by claiming an email they don't own.
  - If an existing user already has oidc_sub set and the incoming
    sub is different, refuse with ?error=sub_mismatch. Prior
    behaviour silently did nothing, hiding a potential attack.
  - Audit 'oidc_linked' written on first successful link.

Frontend:
  - Added user-facing messages for the two new SSO error codes.
2026-04-14 04:35:00 +02:00
Daniel
97f60876c5 Idle timeout observability + cut write frequency in half + hide local-auth UI for SSO-only users
Middleware:
  - Log to console.warn + audit_log when a session is killed for
    inactivity. Shows up in Grafana/Loki so you can see how often
    users actually get kicked. Audit action: 'session_idle_timeout'
  - last_activity throttle bumped 5 min → 10 min — halves DB writes
    per active user. Idle precision slop widens to 24h00-24h10;
    still invisible in practice.

Per-user local-auth visibility:
  - /api/auth/me now returns user.canLocalAuth: true when the stored
    password is a real bcrypt / argon2 hash, false for the random
    blob OIDC auto-creates for SSO-only users.
  - Settings page hides "Change Password" and "Two-Factor
    Authentication" sections when canLocalAuth is false — those UIs
    are meaningless for users whose sign-in lives at the IdP.
  - Password hash is not leaked in the /me payload.

Mobile (restating existing behaviour for clarity): no idle check,
365-day JWT in Keychain/Keystore, never auto-logs-out. Only logout
triggers are: manual logout, password change, admin revoke, JWT hit
365d, or app uninstall.
2026-04-14 04:32:53 +02:00
Daniel
c411e5f16f Sliding 24h idle timeout (web) + persistent mobile + 2FA backup codes
Session model:
  Web     — 24h sliding idle timeout enforced server-side via
             user_sessions.last_activity. 30-day JWT + cookie are a
             safety net; middleware is the real clock. Cookie is
             re-set on active use so browsers match the sliding window.
  Mobile  — 365-day JWT, no idle timeout (stays persistent via Keychain
             / Keystore). Detected via User-Agent ("PedScribe" /
             "Capacitor") or X-Client: mobile header.

2FA backup codes:
  - 10 single-use codes generated when 2FA is first enabled
  - Stored as bcrypt hashes in new users.totp_backup_codes column
  - Consumed atomically on successful login fallback (when TOTP fails)
  - Regenerate endpoint (POST /api/auth/2fa/backup-codes) requires
    current password; invalidates prior codes
  - Count endpoint (GET /api/auth/2fa/backup-codes/count) powers a
    "N codes remaining" indicator on the 2FA settings card
  - Modal shows codes exactly once with Copy + Download .txt actions
  - Codes cleared when 2FA is disabled

New files:
  src/utils/platform.js — isMobileClient() helper

Schema migration (idempotent):
  ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_backup_codes TEXT
2026-04-14 04:24:54 +02:00
Daniel
6c98e36511 Session lifetime: 7 days → 24 hours
Shortens both JWT expiresIn and httpOnly cookie maxAge to 24h in
auth.js (local + register + reset flows) and oidc.js (SSO callback).

Rationale: shorter absolute session window for a PHI-adjacent app.
No sliding idle refresh — user re-logs in once a day.
2026-04-14 04:17:54 +02:00
Daniel
4a29c496f6 Mobile app hardening — security + Android 14 compat
capacitor.config.json:
  - webContentsDebuggingEnabled: true → false
    (was leaving Chrome DevTools able to attach to released builds)
  - allowMixedContent: true → false
    (API is HTTPS-only; no need to permit cleartext loads)
  - server.allowNavigation: ["*"] → restricted to pedshub.com /
    peds.danvics.com origins
    (prevents WebView following an attacker-controlled redirect)

AndroidManifest.xml:
  - android:allowBackup="false" + data_extraction_rules.xml
    (Android system backup would otherwise copy EncryptedSharedPreferences
     containing the auth token into Google Cloud backups)
  - Removed USE_BIOMETRIC permission (feature removed earlier)

AudioRecordingService.java:
  - startForeground(id, notif, TYPE_MICROPHONE) on Android 14+
    (without the explicit type Android 14 kills the service with
     MissingForegroundServiceTypeException)
  - WakeLock cap: 1h → 8h (still bounded, onDestroy releases early)

MainActivity.java:
  - Removed dead biometric code path and androidx.biometric imports

mobile/package.json:
  - Dropped @aparajita/capacitor-biometric-auth — orphan dependency
2026-04-14 04:15:27 +02:00
Daniel
5a700a2a27 Hybrid auth: cookie-only on web, Keychain Bearer on mobile
Runtime split driven by window.Capacitor.isNativePlatform():

  Web browser
    - No token in localStorage / sessionStorage — XSS can't read it
    - Server-set httpOnly cookie carries the session
    - fetch() default credentials='same-origin' sends the cookie
    - getAuthHeaders() returns Content-Type only, no Authorization
    - Middleware already falls back to cookie when Bearer is absent

  Capacitor native (iOS / Android)
    - Unchanged — Bearer token lives in Keychain / Keystore via the
      capacitor-secure-storage-plugin SecureStorage wrapper
    - Bearer header still sent on every request

enterApp() / clearSession() / getAuthHeaders() all now branch on
isNativeApp(). Legacy localStorage entries from the dual-mode era
are wiped on clearSession() for users migrating in.

Rollback: git reset --hard pre-httponly-only-2026-04-14
2026-04-14 04:11:55 +02:00
Daniel
0bbecb76f9 Maintenance CLI + unpin postgres digest
Adds `npm run maint:check` (health report) and `npm run maint:reindex`
(REINDEX DATABASE + REFRESH COLLATION VERSION + ANALYZE) for post-
upgrade maintenance, modelled after Nextcloud's occ maintenance.
Documented in README.

Also relaxes postgres image from digest pin back to tag-pin
(pgvector/pgvector:pg16) — the auto-REINDEX-on-drift check in
database.js and the COLLATE "C" protection on critical indexes
make the digest pin redundant while blocking ordinary `compose
pull` updates.
2026-04-14 04:00:13 +02:00
Daniel
cd2513d361 Pin critical auth indexes to COLLATE "C" (ICU-drift immune)
idx_users_email and idx_sessions_token_hash now use byte-order
collation so a future ICU library bump cannot silently corrupt the
indexes the way it did this week. The columns themselves retain
their default collation; only the index comparison is C, which is
safe for these because:

  - users.email is lowercased ASCII in practice
  - user_sessions.token_hash is SHA-256 hex (pure ASCII)

Both are used for equality lookups only, never ORDER BY. Migration
is idempotent, gated on app_settings.migration.text_indexes_c.

Slug indexes on learning_* tables left at default for now — those
are also ASCII in practice but under lighter load; the startup
drift check + auto-REINDEX covers them.
2026-04-14 03:55:09 +02:00
Daniel
9423ffc3a7 Collation-drift guard + lookup-miss visibility
Root cause of recent "invalid credentials on correct password" was
a silent btree index corruption: pgvector/pgvector:pg16 was pulled
with a different ICU library than the one used to build existing
indexes. Queries returned 0 rows even though matching heap rows
existed. Postgres logged nothing (corrupt index → empty result set
is a "successful" query) and the login path never logged unknown-
user attempts (enumeration protection).

Three defenses:

  1. Pin postgres image by digest in docker-compose.yml so a
     silent pull can't change ICU under our feet.
  2. Startup collation-drift check in src/db/database.js:
     compares pg_database.datcollversion to the library's actual
     version and, on mismatch, runs REINDEX DATABASE + ALTER
     DATABASE REFRESH COLLATION VERSION. Logs "Collation versions:
     aligned" on clean boot.
  3. Server-side console.warn on login lookup-miss (no email, no
     audit row — preserves enumeration protection but gives
     Grafana/Loki a signal for unusual miss rates).
2026-04-14 03:52:29 +02:00
Daniel
43d26fd306 Login: remove temporary debug logging
Root cause for "invalid credentials" on correct password was a
corrupt btree index (idx_users_email) causing user lookups to miss
existing rows. Fixed by REINDEX DATABASE. Keeping a typed catch
around passwords.verify() so any future verify throw is logged
cleanly instead of bubbling as 500.
2026-04-14 03:48:56 +02:00
Daniel
11f53102ee Growth/BMI results: percentiles to 2 decimal places
Percentile displays in growth charts, BMI, and mid-parental height
now show 2 dp (e.g. "37.42th") instead of 1 dp ("37.4th") for more
precision at tail percentiles.
2026-04-14 03:37:40 +02:00
Daniel
7cc8a1fa99 Growth chart: accept explicit 0 in any age field
Previously any form of zero total ("0 days", all blank) rejected
with "Enter age". Newborns at birth are a legitimate entry —
distinguish blank-all (error) from explicit-zero (valid).
2026-04-14 03:33:10 +02:00
Daniel
df592d401b Growth chart age: three boxes (yr/mo/day), any combination
Replace single text input with three number fields — years, months,
days — that all combine into fractional months. Fill any subset:
leave years blank for a newborn, leave months blank for "2 years",
enter just days for a 10-day-old.

Live hint below ("= 2 yr 5 mo (29 mo total)") still shows the
interpreted total. Parser from prior commit retained on window
for reuse elsewhere.
2026-04-14 03:21:27 +02:00
Daniel
c392e73cfe Growth chart: flexible age input with smart parser
Replace [years] + [months dropdown] with a single text field that
accepts:
  3y / 3 years / 3 yr
  29m / 29 months / 29 mo
  2y5m / 2 years 5 months / 2 yr 5 mo
  3.5 years / 36 (plain number = months)
  15 days / 2 weeks / 3y 2m 10d

Enables fractional ages so newborns can be plotted accurately
(WHO/CDC growth curves are continuous — "0 months" means at birth,
not a 0-27 day bucket, so a 15-day-old should plot at ~0.5 months).

Live hint below the field shows how the input was interpreted
("= 2 yr 5 mo (29 mo total)").
2026-04-14 03:17:16 +02:00
Daniel
dc5f8ae758 Dockerfile: add build tools for argon2 native compile
argon2 requires node-gyp + python3 + g++ + make to build its C
extension. Added as a virtual .build-deps package so it's compiled
during npm install, then purged to keep the Alpine image slim.
2026-04-14 03:09:38 +02:00
Daniel
74c5cde8e1 Stop leaking e.message to clients across all routes
88 occurrences of res.status(500).json({ error: e.message }) (or
err.message) swept to generic 'Request failed'. Server-side
console.error / logger.error calls are untouched, so the full detail
still lands in logs and Grafana.

Covers: admin, adminConfig, adminMilestones, chartReview, documents,
encounters, hospitalCourse, hpi, learningAdmin, learningAI, learningHub,
logs, memories, milestones, oidc, refine, sessions, sickVisit, soap,
userPreferences, wellVisit.

Also extends .gitignore to exclude .env.backup-* files.
2026-04-14 03:04:24 +02:00
Daniel
7c45367c02 Security hardening: PHI encryption, argon2, DOMPurify, SRI
- App-layer AES-256-GCM crypto helper (src/utils/crypto.js)
- Nextcloud tokens encrypted at rest; transparent migration on next use
- Audio backups encrypted at rest (version byte 0x01 envelope); legacy
  rows still decrypt as-is until overwritten
- argon2id password hashing via src/utils/passwords.js with bcrypt
  fallback; bcrypt hashes rehashed to argon2id on next successful login.
  argon2 package is optional — server keeps running with bcrypt only
  until npm install adds the native dep
- PHI redactor for audit log details (src/utils/redact.js) — strips SSN,
  phone, email, DoB, long IDs; caps at 500 chars; detects note bodies
- DOMPurify (cdnjs, SRI-pinned) replaces custom regex sanitizer in
  Learning Hub content rendering
- SRI integrity hashes added for Font Awesome CSS and Chart.js
- Magic-byte file-type verification on document uploads
  (src/utils/fileType.js)
- Generic 500 error responses via src/utils/errors.js applied to
  nextcloud and audioBackups; full detail still logged server-side
- DATA_ENCRYPTION_KEY env documented in .env.example

Deploy: requires rebuild of the container image to pick up the new
files and `npm install` (adds argon2). Existing users keep working
because bcrypt stays available and crypto helpers pass through
plaintext when the key is not yet set in dev.
2026-04-14 02:49:38 +02:00
Daniel
e625c634b6 Security hardening: low-risk easy wins
- JWT_SECRET fails fast at startup in production
- CORS fails closed if APP_URL + CORS_ORIGINS are both missing
- Explicit HSTS (1y, includeSubDomains, preload)
- Rate limit sensitive auth endpoints (change-password, 2FA)
- /api/health now returns {ok:true}; details gated behind admin auth
- Login enumeration removed — generic 401 + dummy bcrypt on miss
- ReDoS guard: 20KB input cap on /suggest-codes
- showToast uses textContent, no innerHTML
- clearSession() clears service worker caches on logout
- OIDC state is now HMAC-signed and stateless (survives restart)
- SSRF guard on admin-set OIDC issuer (blocks private IPs, requires HTTPS)

Adds docs/mobile-build.md covering APK build, release, git push,
keystore, and troubleshooting for both PedScribe and PedsHub apps.
2026-04-14 02:42:32 +02:00
Daniel
c736782c15 Add hardware-backed secure storage for mobile auth token
Web still uses localStorage; Capacitor native app now routes
token/user/session-id through capacitor-secure-storage-plugin
(iOS Keychain, Android EncryptedSharedPreferences / Keystore).

A thin SecureStorage wrapper detects Capacitor at runtime and
falls back to localStorage elsewhere, keeping a single auth.js
codebase for both targets.

To activate on mobile: cd mobile && npm install && npx cap sync android
2026-04-14 02:33:32 +02:00
Daniel
82b8fa0e0e Add APK download link on login page
Links to GitHub releases/latest for Android APK download.
2026-04-14 02:29:38 +02:00
Daniel
011fae9b7a Enhance audit logging: user agent, session ID, PHI access tracking
Loki logs now include:
- User agent string (browser/device identification)
- Session ID (ties actions to specific login session)
- Status field (success/failure)

New logging:
- encounter_load: logged when user opens a saved encounter (with label)
- copy_to_clipboard: logged when user copies note content (PHI access)
- Client event endpoint: POST /api/logs/client-event (auth required)

Encounter save/delete/load all include the encounter label for
patient identification in audit trail.

HIPAA audit trail now covers: who, what, when, from where, which
device, which session, what patient data, success/failure.
2026-04-11 06:17:05 +02:00
Daniel
ffa6b818db Add full hour-by-hour exchange transfusion thresholds for all GA groups
Exchange transfusion data (AAP 2022) now covers GA 35, 36, and 38+ weeks
with and without risk factors, hour-by-hour from 12-96h (510 more data
points). Total bilirubin data: 1020 data points (6 photo + 6 exchange
tables x 85 hours each). No interpolation needed for any hour.
2026-04-11 06:03:48 +02:00
Daniel
ab1ac25611 Bilirubin: full hour-by-hour AAP 2022 data (510 data points)
Replace interpolated thresholds with exact hour-by-hour values
extracted from PediTools API for every hour from 12-96h:
- 6 phototherapy tables (GA 35/36/38 x with/without risk factors)
- 85 data points per table = 510 total values
- No interpolation needed — exact AAP 2022 nomogram values
- Exchange transfusion thresholds for GA 38 (with/without risk)
2026-04-11 06:01:00 +02:00
Daniel
1b5faa3a01 Update bilirubin to exact AAP 2022 values, add exchange transfusion
Phototherapy thresholds updated with exact values extracted from
PediTools (validated against AAP 2022 nomograms):
- Separate tables for GA 35, 36, and 38+ weeks
- With and without neurotoxicity risk factors
- Hour-specific values at 12, 24, 36, 48, 60, 72, 84, 96, 120h

Previous approximations were 1-3 mg/dL too low (conservative but
inaccurate). New values match the published AAP 2022 curves exactly.

Exchange transfusion thresholds added for GA 38+ weeks (with/without
risk factors). Displayed alongside phototherapy threshold in results.

GA selection expanded: 35, 36, 37, 38, 39, 40+ weeks.
Chart now shows both phototherapy and exchange transfusion lines.

Also: fixed Loki port conflict (3100->3101), added logs.pedshub.com.
2026-04-11 05:50:19 +02:00
Daniel
5bf55499a4 Add pause/stop buttons to SOAP note recording
- Add Pause and Stop buttons (hidden until recording starts)
- Record button hides during recording (same pattern as encounter)
- Pause: suspends MediaRecorder + speech recognition, shows Resume
- Resume: handles MediaRecorder state recovery if browser killed it
- Stop: triggers the record button's stop flow
- Native mobile: haptic feedback + keep-awake + foreground service
- Recognition respects pause state (doesn't restart during pause)
2026-04-11 05:19:12 +02:00
Daniel
6ed2778a12 Add Glasgow Coma Scale calculator and equipment sizing reference
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
GCS Calculator:
- Child/Adult and Infant versions with toggle
- Eye opening (4), Verbal (5), Motor (6) dropdowns
- Auto-calculates total score with severity classification
  (Mild 13-15, Moderate 9-12, Severe/Coma 3-8)
- Infant-modified verbal and motor scales per Kids Kard
- Updates on every dropdown change (no button needed)

Equipment Sizing (Johns Hopkins Kids Kard):
- Select age/weight group (premie through 16+)
- Shows: BVM, oral/nasal airway, blade, ETT, LMA, Glidescope,
  IV catheter, central line, NGT/OGT, chest tube, Foley
- All values from Johns Hopkins Children's Center Kids Kard
- ETT formulas shown as reference
2026-04-11 05:01:57 +02:00
Daniel
6daf08982e Increase API rate limit to 200 req/min (Turnstile errors were exhausting 60/min limit)
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
Build TWA APK / build-apk (push) Has been cancelled
2026-04-11 04:47:08 +02:00
Daniel
9f39f0b822 Fix vital signs selector, add resuscitation medications calculator
Vital Signs:
- Fix age selector not responding (replaced setTimeout with event
  delegation on parent panel — works reliably with hidden panels)
- Update values to Johns Hopkins Kids Kard data (8 age groups:
  premie, 0-3mo, 3-6mo, 6-12mo, 1-3yr, 3-6yr, 6-12yr, >12yr)
- Each age group shows: HR awake/sleeping, RR, SBP, DBP, temp,
  SpO2, weight range, and clinical pearls

Resuscitation Medications (new calculator tab):
- Enter patient weight, calculates all 13 PALS medication doses
- Adenosine, Amiodarone, Atropine, Calcium Chloride/Gluconate,
  Dextrose (weight-based concentration), Epinephrine (arrest/anaphylaxis),
  Hydrocortisone, Insulin, Lidocaine, Magnesium, Naloxone, Bicarb
- Color-coded by category (cardiac/metabolic/reversal)
- Max dose capping, route, special notes per medication
- Source: Johns Hopkins Kids Kard / AHA PALS 2020
2026-04-11 04:42:23 +02:00
Daniel
09aaeefee1 Fix vital signs age selector: add setTimeout for DOM readiness 2026-04-11 04:28:49 +02:00
Daniel
a5f073dcdd Interactive vital signs selector with clinical notes per age group
Replace static vital signs table with interactive age group dropdown.
Each selection shows: HR (awake/sleeping), RR, SBP, DBP, temperature,
SpO2 target, weight range, and age-specific clinical notes.

10 age groups: preterm through 18 years. Values from Harriet Lane
Handbook 23rd Edition. Includes AAP 2017 BP classification thresholds
for ages 13+, ETT sizing formulas, and clinical pearls (orthostatic
testing, febrile tachycardia, athletic bradycardia, etc.).

Full reference table preserved as collapsible "View All Age Groups".
2026-04-11 04:19:39 +02:00
Daniel
79fee2d4f2 Remove biometric prompt (will implement properly with token-based auth later) 2026-04-11 03:57:40 +02:00
Daniel
e1ce374809 Native Android: biometric auth, foreground service bridge, mic fix
Major Android native improvements:

Biometric authentication:
- Native AndroidX BiometricPrompt on app launch (2nd launch onwards)
- Supports fingerprint, face, iris, and device PIN/password fallback
- Gracefully skips if no biometric hardware or first launch
- Uses SharedPreferences to track first launch

Microphone permission:
- Added MODIFY_AUDIO_SETTINGS permission (required for WebView audio)
- Added androidScheme: "https" in Capacitor config (getUserMedia requires
  secure context)
- WebChromeClient properly grants WebView permission after Android
  runtime permission is obtained
- Handles pending permission request across the async flow

Background recording bridge:
- NativeRecording JavaScript interface exposed to WebView
- startForegroundService() / stopForegroundService() callable from JS
- Web app calls these on recording start/stop in liveEncounter.js
- AudioRecordingService keeps CPU awake + shows notification when recording
- Recording survives screen lock via foreground service + wake lock

Also:
- USE_BIOMETRIC permission added to manifest
- androidx.biometric:biometric dependency added to build.gradle
- Haptic fallback to navigator.vibrate when Capacitor plugins unavailable
2026-04-11 03:42:23 +02:00
Daniel
4b1afd1f44 Fix WebView mic: grant both Android runtime + WebView permissions
The WebView has its own permission layer separate from Android runtime
permissions. Both must be granted. Now when the web page requests mic
access, the WebChromeClient checks if Android permission exists, grants
the WebView request if yes, or requests Android permission first then
grants the pending WebView request in the callback.
2026-04-11 03:37:45 +02:00
Daniel
6d3b0693d8 Fix Android mic permission, simplify launcher, remove broken biometric
- MainActivity: request RECORD_AUDIO permission at app start via
  ActivityCompat (not WebChromeClient override which broke Capacitor bridge)
- Simplify launcher: remove server reachability check (was failing in
  WebView), just save URL and navigate directly
- Remove biometric auth from launcher (Capacitor plugins need ES module
  bundler, not available in plain HTML). Biometric can be added later
  via the web app with proper Capacitor runtime.
- Add webContentsDebuggingEnabled for development
2026-04-11 03:34:47 +02:00
Daniel
7f8ddfff53 Fix Android: auto-grant WebView mic permission, match status bar color
- MainActivity: override WebChromeClient to auto-grant WebView
  permission requests (microphone, camera) so the Android runtime
  permission dialog shows instead of WebView silently denying
- Add colors.xml with PedScribe blue (#2563eb / #1d4ed8)
- Update styles.xml: set statusBarColor and navigationBarColor to
  match app theme (fixes brown/mismatched bar at top)
- Change base theme to NoActionBar (removes action bar)
2026-04-11 03:29:15 +02:00
Daniel
6b69315d99 Fix launcher: simplify server check for Android WebView compatibility
WebView blocks no-cors fetch and image probes differently than browsers.
Simplified to a normal fetch that treats CORS errors as 'server reachable'
(CORS error = server responded, just blocked the origin).
2026-04-11 03:23:00 +02:00
Daniel
47844ff29b Add Loki + Grafana monitoring stack, ntfy notifications, biometric auth
Monitoring:
- docker-compose.monitoring.yml — opt-in Loki + Grafana stack
- Loki config with 6-year retention (HIPAA compliant)
- Grafana auto-provisioned with Loki datasource + PedScribe dashboard
  (login activity, failed logins, clinical actions, API calls, log viewer)
- Logger ships to Loki in parallel with PostgreSQL (fire-and-forget)
- Labels: app=pedscribe, type=audit|api_call|access, category, action

Usage: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
Grafana at localhost:3003 (admin/pedscribe)

Notifications:
- ntfy push support (src/utils/notify.js)
- Notifications on: login, password change, registration
- Self-hosted, no Firebase dependency

Mobile:
- Biometric auth on app launch (Face ID/Touch ID/fingerprint)
- PIN/password fallback, auto-prompt, skip option
2026-04-11 03:00:52 +02:00
Daniel
c7038d9db1 Add biometric auth, ntfy push notifications, mobile improvements
Mobile:
- Add biometric authentication (Face ID/Touch ID/fingerprint) on app launch
  with PIN/password fallback, auto-prompts on launch, skip option
- Add @aparajita/capacitor-biometric-auth plugin

Backend:
- Add ntfy push notification support (src/utils/notify.js)
  Self-hosted, no Firebase dependency, uses user's existing ntfy instance
- Notifications for: new login, password changed, new registration (admin)
- Topic format: pedscribe-user-{id} for users, pedscribe-admin for admins
- Env: NTFY_URL, NTFY_TOKEN (optional)
2026-04-11 02:35:07 +02:00
Daniel
11d4880337 Mobile app: haptics, deep linking, share intent, push notifications, keep-awake
Native improvements:
- Add haptic feedback on recording start (heavy) and stop (medium)
- Add keep-screen-awake during recording (nativeKeepAwake)
- Add isNativeApp() detection helper
- Android: deep linking (pedscribe:// + https://app.pedshub.com)
- Android: share intent for text/plain and application/pdf
- iOS: deep linking (pedscribe:// URL scheme)
- iOS: remote-notification background mode
- Add Capacitor plugins: haptics, keyboard, push-notifications,
  screen-orientation, share

Updated README with complete build/deploy instructions,
App Store listing suggestions, and icon generation guide.
2026-04-11 02:28:30 +02:00
Daniel
91d04f852f Fix mobile app bugs: package name, deprecated API, server check
- Fix AudioRecordingService ACTION_STOP to use com.pedshub.scribe
- Fix deprecated stopForeground(true) to STOP_FOREGROUND_REMOVE
- Fix launcher.js testServer: prevent double callback, fix onerror
  always reporting success (now correctly fails on unreachable servers)
- Update service comment from TWA to Capacitor
2026-04-11 02:22:32 +02:00
Daniel
7d55e64ca1 Add Capacitor native mobile app (PedScribe) for iOS + Android
New mobile/ directory with Capacitor project:
- Configurable server URL launcher (default: app.pedshub.com)
- Android: foreground service + wake lock for background recording
  (AudioRecordingService preserved from existing TWA)
- iOS: background audio mode + microphone permission
- App ID: com.pedshub.scribe
- Both platforms initialized and synced

Existing android/ TWA project untouched — this is a separate project.
Build: cd mobile && npx cap open android (or ios)
2026-04-11 02:18:06 +02:00
Daniel
639a5d2873 Add automatic ICD-10 and CPT billing code suggestions
New feature: after generating any clinical note, the app automatically
suggests relevant billing codes displayed as clickable chips below the output.

Backend (src/routes/billing.js):
- POST /api/suggest-codes endpoint analyzes note text
- Extracts diagnoses from Assessment section via regex
- Looks up ICD-10 codes: local common pediatric map (40+ conditions)
  first, then NLM Clinical Tables API for unknown terms
- Suggests CPT E/M codes based on note type, visit complexity,
  ROS/PE system counts, and MDM level estimation
- Supports: outpatient (new/established), well visit (age-based),
  ED, inpatient (admit/subsequent/discharge)

Frontend (public/js/app.js):
- suggestBillingCodes() renders collapsible card with ICD-10 and CPT chips
- Click any chip to copy the code to clipboard
- Shows E/M level assessment (diagnosis count, ROS, PE, MDM complexity)
- Disclaimer: "Suggestions only. Always verify codes."

Integration: called after note generation in all 6 tabs
(encounter, SOAP, sick visit, well visit, hospital course, chart review)
2026-04-11 01:50:17 +02:00
Daniel
e7eb695049 Add pediatric calculators: BP, BMI, growth, bilirubin, vitals, BSA, dosing
Calculators tab with 7 tools:
- BP Percentile (AAP 2017) with age/sex/height classification
- BMI Percentile (CDC 2000) with extended obesity classification
  (Class 1/2/3 using % of 95th percentile per CDC 2022)
- Growth Charts: weight-for-age, length-for-age, head circumference,
  weight-for-length (WHO/CDC LMS), Fenton preterm (22-50 weeks)
- Bilirubin: AAP 2022 phototherapy threshold + Bhutani nomogram
  with Nelson Table 137.1 risk factors for severe hyperbilirubinemia
- Vital Signs by Age (Harriet Lane) with quick reference formulas
  (estimated weight, min SBP, ETT size, maintenance fluids 4-2-1)
- Body Surface Area (Mosteller formula)
- Weight-Based Dosing with max cap and volume calculation

Fix growth chart sub-tab navigation (pills scoped separately from
top-level nav to prevent panel disappearing)
2026-04-09 17:56:30 +02:00
Daniel
3d5b77721c Replace all browser dialogs with modern modal, add OIDC admin UI
- Add reusable showConfirm() modal component (supports plain confirm,
  input prompt, danger styling, Enter key)
- Replace ALL 18 confirm() and prompt() calls across 8 JS files with
  showConfirm() modal: admin user actions, session revoke, document
  delete, template delete, milestone management, transcription settings
- Fix broken admin reset-password (btn was undefined in scope)
- Add OIDC/SSO configuration UI to Admin Panel (issuer, client ID/secret,
  button label, disable local auth toggle, callback URL display)
2026-04-09 02:43:23 +02:00
Daniel
719fe0533f Fix session revocation bug that could log out current device
- Fix: DELETE all other sessions query used empty string fallback when
  req.sessionId was undefined, causing id != '' to match ALL rows
  (including current session). Now skips deletion if sessionId unknown.
- Fix: Revoke All endpoint returns error if current session not identified
- Fix: var confirm shadowing window.confirm in password change handler
2026-04-09 02:33:00 +02:00
Daniel
85f9af4ffc Remove prompt() dialogs, breach warnings, cost display; fix 2FA disable UI
- Replace browser prompt() with inline UI for: 2FA disable (password field),
  admin password reset (inline input), admin test email (inline input)
- Remove all password breach warning UI (login, register, settings)
  Backend HIBP check endpoint remains but is no longer called from frontend
- Remove model cost display from dropdown and header badge
- Hide empty cost-badge element in header
- Fix model dropdown to flat list (no category grouping)
2026-04-09 02:27:55 +02:00
Daniel
55f8e172e6 FAQ page, dep security patches, model dropdown and UI fixes
- Add FAQ tab with accordion sections: Getting Started, AI & Models,
  Voice & Transcription, Saving & Export, Privacy & Security,
  Well Visit & Sick Visit, Learning Hub, Troubleshooting
- Documents how AI learns from physician edits (correction tracker)
- Fix FAQ accordion (CSP was blocking inline script, moved to app.js)
- Patch all 5 npm vulnerabilities: nodemailer 8.0.5, xmldom, basic-ftp,
  path-to-regexp (npm audit now reports 0 vulnerabilities)
- Remove model category grouping from dropdown (flat list, no optgroups)
- Fix model dropdown dark background on options (white bg, dark text)
- Update FAQ model guidance to reflect admin-managed model selection
2026-04-09 01:56:11 +02:00
Daniel
09193538fb v6.2: Session management, password change, audit logging, refine context, UI fixes
Security:
- Add session management: users can view/revoke active sessions in Settings
- Add password change in Settings (requires current password, HIBP check)
- Force logout all sessions on password reset
- Fix logout to destroy server-side session (was only clearing cookie)
- Add trust proxy for correct client IP in rate limiting and audit logs
- Add CORS support for multiple domains (CORS_ORIGINS env var)
- Add HIBP breach check endpoint and inline warnings on password fields

Audit logging:
- Add audit logging to all 24 PHI-handling endpoints across 13 route files
- Covers: generation, transcription, TTS, refine, encounters, documents, Nextcloud
- All fire-and-forget (no response delay)

AI improvements:
- Refine now includes original source material (transcript, notes, labs)
  so AI can reference the full input when modifying output
- Add correction tracking (trackAIOutput) to sick visit and well visit tabs
- Fix sickvisit missing from encounter save noteIdMap

UI fixes:
- Non-blocking busy bar for transcription and AI generation (replaces full-screen overlay)
- Fix encounter recording: hide record button during recording (was showing two stop buttons)
- Fix ROS/PE "All WNL" stacking duplicate event handlers; add Clear buttons
- Enlarge AI instructions textarea in Learning Hub CMS

Domain:
- Primary domain now app.pedshub.com, with scribe.pedshub.com and peds.danvics.com as CORS origins
2026-04-08 20:27:45 +02:00
Daniel
b3b54c9a6c Add developer guide, expand admin model management docs
- New docs/developer-guide.md: full walkthrough of frontend SPA architecture,
  backend middleware stack, database layer, AI integration, settings system,
  how to add features/routes/tables, key design decisions, file references
- Expand ai-providers.md: detailed admin model management (add custom models
  with ID/name/cost/category, discover from provider, enable/disable, set default)
- Update README docs index
2026-04-04 23:02:02 +02:00
Daniel
869fa14a77 v6.1: Turnstile bot protection, LiteLLM provider, PPTX tables, audio backup fixes, docs
- Add Cloudflare Turnstile to login, register, and password reset forms
- Switch AI provider to LiteLLM, transcription to OpenAI Whisper
- Change domain to scribe.pedshub.com
- Fix PPTX export: add tables, bold/italic, numbered lists, code blocks, blockquotes
- Fix announcement banner close button (CSP was blocking inline onclick)
- Fix auth middleware: empty Bearer token now falls through to cookie auth
- Fix audio backups: only save on transcription failure, stop auto-deleting on success
- Soften AI correction injection to prevent model hallucination from correction history
- Fix LiteLLM TTS model name handling (no incorrect openai/ prefix)
- Expand AI instructions textarea in Learning Hub CMS
- Update README for v6 with all features and providers
- Add comprehensive docs/: architecture, API reference, database schema,
  authentication, AI providers, speech, learning hub, configuration, deployment
2026-04-04 22:56:24 +02:00
ifedan-ed
783679a3f7 feat: Add model search, testing, and TTS/STT/embedding management to admin
- Fix model search for all providers: Bedrock now falls back to built-in
  list (with live ListFoundationModels attempt), Azure returns built-in list
- Add Test button on every model row (built-in, discovered, custom) that
  sends a live prompt and shows response + latency in a toast
- Add TTS management section: search voices from provider API (Google TTS
  voices.list, LiteLLM /v1/models, ElevenLabs /v1/voices), Set as Default
  writes tts.voice/tts.model to DB, runtime respects DB override
- Add STT management section: search models from provider (Gemini, Whisper,
  LiteLLM, OpenAI, local), Set as Default writes stt.model to DB, runtime
  respects DB override in transcribe.js
- Add Embedding models section: search from provider (LiteLLM, Vertex,
  OpenAI), Set as Default writes embeddings.model+dimensions to DB,
  embeddings.js respects DB override
- Add record-and-transcribe STT test (browser MediaRecorder)
- Add TTS synthesize-and-play test (returns base64 audio)
- Add embedding generate test (shows dims + vector sample)
- Expand PUT /config/:key(*) whitelist to include tts., stt., embeddings.
- Add @aws-sdk/client-bedrock as optional dependency for live Bedrock discovery
2026-04-03 19:55:11 +00:00
ifedan-ed
8bd5cbd690 v2.2: Remove milestone admin UI, add CMS content refresh button
REMOVED:
- Milestone editing UI from Admin Panel (per user request)
- Milestones will be managed via hardcoded static data only
- Kept backend routes and database support for future use

ADDED:
- Refresh button in Learning Hub CMS content list
- Manual refresh for AI-generated content updates
- Better discoverability of content refresh functionality

FIXES:
- AI learning content now has visible refresh button
- Users can manually refresh content list after AI generation
- Cleaner admin panel without milestone management clutter

NOTE:
- Developmental milestones still work via static fallback
- Edit milestones by modifying public/js/milestonesData.js
- Backend API still supports milestone management if needed later
2026-04-01 18:16:00 +00:00
ifedan-ed
fdf29b5ed7 v2.1: Add visible bulk import UI for developmental milestones
NEW FEATURES:
- Bulk Import button in Admin Panel → Developmental Milestones section
- "Import Default Milestones Data" button appears when database is empty
- "Re-import All" button to clear and re-import all static data
- Visible notice when no milestones exist with one-click import

IMPROVEMENTS:
- Auto-shows empty state notice when database has no milestones
- Backend bulk-import endpoint now supports clearExisting parameter
- Imports ALL age groups from static data (birth to 11 years)
- Better UX - admin doesn't need CLI to populate milestone data

FIXES:
- Makes milestone admin editing feature discoverable and usable
- No need to manually run import script anymore
2026-04-01 18:06:42 +00:00
ifedan-ed
dc2e000e88 v4: Fix milestones display + add OpenID auth + 100MB PDF support
FIXES:
- Milestones now show correctly on encounter page (use static fallback if DB empty)
- Static data preserved as MILESTONES_DATA_STATIC for compatibility
- Database-driven milestones still work (admin can edit via CMS)

NEW FEATURES:
- OpenID Connect (OIDC) authentication support (PocketID, Keycloak, Azure AD, etc.)
- Comprehensive setup guide: OPENID_SETUP.md
- Auto-linking existing users by email on SSO login
- Multiple PDF upload support in Learning Hub (up to 10 files)
- 100 MB per file limit (was 20 MB)
- Full PDF content used for AI generation
- Embeddings use first ~8K chars for semantic search

IMPROVEMENTS:
- Updated UI to show multiple file selection with list
- Drag-and-drop supports multiple files
- Better file upload validation and error handling
- Added clarifying comments about embedding truncation
2026-04-01 17:59:51 +00:00
ifedan-ed
540347c015 v6: Use transformers.js v2.0.0 (proven worker compatibility)
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
2026-04-01 00:32:23 +00:00
Daniel Onyejesi
ba6724083c Bump docker-compose to v6 2026-03-31 20:09:42 -04:00
ifedan-ed
17557fa0f8 Version 5.0.0 - Browser Whisper fix with self-hosted v2.6.2
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
Build TWA APK / build-apk (push) Has been cancelled
2026-03-31 23:32:07 +00:00
ifedan-ed
39d9a1f9e8 Update docs for v3 truly self-hosted setup 2026-03-31 23:12:46 +00:00
ifedan-ed
0dc6812f38 FIX: Browser Whisper - 100% self-hosted, zero CDN dependencies
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
FINAL WORKING SOLUTION:

Previous attempts failed because:
- transformers.js v2.17.2 is ES module-only
- Module workers require complex CSP and external imports
- importScripts() doesn't work with ES modules

Solution:
- Use transformers.js v2.6.2 (has worker-compatible UMD build)
- Bundle library + models, serve entirely from our server
- Classic worker with importScripts() - no CSP issues

What's self-hosted:
-  transformers.min.js (760KB) - at /models/transformers.min.js
-  Whisper models (42MB) - at /models/Xenova/whisper-tiny.en/

Worker loads:
1. importScripts('/models/transformers.min.js') - OUR SERVER
2. Loads models from /models/ - OUR SERVER
3. ZERO external network calls
4. Works in any network (firewalled, air-gapped, etc.)

This is the production-ready, truly offline solution.
2026-03-31 23:12:21 +00:00
ifedan-ed
932ddc3b0a Fix Browser Whisper: Use ES module worker with CDN library
Issue: transformers.js is an ES module package and cannot be loaded
with importScripts() in classic workers.

Solution:
- Changed to module worker (type: 'module')
- Import transformers.js from CDN as ES module
- Models (42MB) still served from local server at /models/

Trade-off:
- Library (900KB): Loads from cdn.jsdelivr.net once, cached
- Models (42MB): Self-hosted, served from /models/ (no CDN)

This is necessary because:
1. @xenova/transformers is ES module-only (package.json: "type": "module")
2. ES modules cannot use importScripts()
3. Module workers require HTTPS for imports
4. CDN is HTTPS and cacheable

If CDN is blocked:
- Use Web Speech API (with privacy warnings)
- OR use Server Transcription (Vertex AI/AWS)

Models remain self-hosted as they're 40MB+ and contain the AI.
2026-03-31 22:55:28 +00:00
ifedan-ed
3c6acb3eb5 Version 3.0.0 - Milestones admin + transcription options 2026-03-31 22:12:58 +00:00
ifedan-ed
d53b469717 Add comprehensive transcription options documentation 2026-03-31 21:58:17 +00:00
ifedan-ed
196f4432f0 Add Web Speech Recognition option for real-time streaming
Provides two transcription options:

1. Browser Whisper (Offline, Batch) - RECOMMENDED
   - 100% offline, zero network calls
   - HIPAA-compliant, audio never leaves device
   - Highest accuracy (Whisper)
   - Processes after recording (batch mode)
   - Models self-hosted, bundled in v2

2. Web Speech API (Real-time, Streaming) - EXPERIMENTAL
   - Real-time transcription (see words as you speak)
   - Uses browser's built-in speech recognition
   - ⚠️ Sends audio to cloud (Chrome/Edge → Google)
   - ⚠️ NOT HIPAA-compliant
   - Requires user consent with clear warnings

Features:
- Settings UI for both options
- Clear privacy warnings for Web Speech
- Mutual exclusion (only one active at a time)
- Browser detection shows which provider is used
- Confirmation dialog before enabling Web Speech

Use Cases:
- Clinical/HIPAA: Use Browser Whisper only
- Personal/Non-clinical: Can use Web Speech for real-time feedback
- Maximum privacy: Browser Whisper (offline)
- Maximum speed: Web Speech (if privacy not required)

Implementation:
- speechRecognition.js: Web Speech API wrapper
- transcriptionSettings.js: Settings UI handler
- Privacy info displayed per browser

User can choose based on their privacy vs. speed preference.
2026-03-31 21:57:24 +00:00
ifedan-ed
a7dd08c9d1 Add admin dashboard for developmental milestones management
Features:
- Admin can add, edit, and delete developmental milestones via dashboard
- Milestones stored in PostgreSQL (developmental_milestones table)
- Client-side loads milestones from API instead of static file
- Import script to migrate existing static data to database
- Organized by age group and domain
- Supports sorting and filtering

Admin UI:
- New section in Admin panel for milestone management
- Filter by age group
- Add/Edit modal with validation
- Delete with confirmation
- Auto-complete for age groups and domains

API Endpoints:
- GET /api/milestones-data - Public endpoint for authenticated users
- GET /api/admin/milestones - List all milestones (admin only)
- GET /api/admin/milestones/meta - Get age groups and domains
- POST /api/admin/milestones - Create milestone
- PUT /api/admin/milestones/:id - Update milestone
- DELETE /api/admin/milestones/:id - Delete milestone
- POST /api/admin/milestones/bulk-import - Bulk import

Usage:
1. Run import script: node scripts/import-milestones.js
2. Access Admin dashboard → Developmental Milestones section
3. Add/Edit/Delete milestones as needed
2026-03-31 20:55:41 +00:00
ifedan-ed
2d1723f14a Change version to v2.0
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
2026-03-31 20:51:50 +00:00
ifedan-ed
9d817cd9f5 v18: Self-hosted Browser Whisper (zero CDN dependencies)
BREAKING FIX: Browser Whisper now fully self-contained

Previous issue:
- Loaded transformers.js from cdn.jsdelivr.net
- Downloaded models from cdn-lfs.huggingface.co
- Failed in corporate/clinical networks with firewall
- Stuck at "Initializing..." with no progress

Solution:
- Bundle transformers.js library (~876KB)
- Bundle Whisper tiny.en model (~42MB)
- Serve everything from local server
- Works in ANY network environment

Changes:
- whisperWorker.js: Load transformers from /models/ instead of CDN
- Dockerfile: Download models during Docker build
- Add download script for local dev
- Add comprehensive setup documentation

Docker image size: +~42MB (one-time cost, runtime benefit)

Tested: Works on unrestricted and firewalled networks
2026-03-31 20:02:11 +00:00
ifedan-ed
b9ceca8f20 v17: Production release with all fixes
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
Complete Feature Set:
 Vertex AI Embeddings - Semantic search for Learning Hub
 Voice Preferences - Per-user STT model + TTS voice selection
 Browser Whisper - Optional client-side transcription with graceful CDN fallback
 TTS Preview - Working for all voices including server default
 Audio Backups - Automatic recording backup with 24h retention
 S3 Documents - Upload/manage documents (AWS, B2, MinIO)
 Learning Hub - AI content generation from PDFs/Nextcloud

Fixed Issues:
- TTS preview button now working (correct event listener)
- Browser Whisper shows clear warning if CDN blocked
- Server default voice preview working
- Graceful fallback to server transcription
- User-friendly error messages throughout

Documentation:
- FEATURES_EXPLAINED.md - Complete feature guide
- BROWSER_WHISPER_TROUBLESHOOTING.md - CDN blocking troubleshooting
- EMBEDDINGS_SETUP.md - Vector search setup guide

Production Ready:
- All features tested
- Clear error handling
- Graceful degradation
- HIPAA-compliant options available
2026-03-31 16:20:41 +00:00
ifedan-ed
c38ce9445e v16: Make Browser Whisper CDN failure graceful with clear warnings
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
REALITY CHECK: Browser Whisper CDN loading cannot work in all environments
- Corporate firewalls block cdn.jsdelivr.net
- Network proxies filter JavaScript CDN
- Workers + importScripts + cross-origin = blocked by CSP/CORS

SOLUTION: Graceful degradation
- Clear user-friendly error messages
- Automatic fallback to server transcription
- Warning banner in Settings if CDN blocked
- Comprehensive troubleshooting documentation

Changes:
- browserWhisper.js: Show toast on worker error, fallback gracefully
- app.js: Display CSP warning banner on preload failure
- settings.html: Add warning about network/firewall requirements
- BROWSER_WHISPER_TROUBLESHOOTING.md: Complete guide for users

Key Message:
Browser Whisper is OPTIONAL. Server transcription (Google/AWS/OpenAI)
is the primary method and works everywhere. Browser Whisper is a
privacy-focused bonus feature that requires CDN access.

User Experience:
- If CDN works: Great! Browser Whisper available
- If CDN blocked: No problem! Server transcription works perfectly
- Clear messaging: User knows what to expect
2026-03-31 16:18:46 +00:00
ifedan-ed
d1f44c2f41 Fix TTS preview for 'Server default' voice option
- Allow empty voice value to preview server default
- Display 'server default' in preview text
- Clears user preference (sets to null) when testing default
2026-03-31 16:15:41 +00:00
ifedan-ed
0d685070d1 v16: TTS Preview + Browser Whisper fixes with correct CSP
Critical fixes from v15:
- TTS Preview: Fixed event listener (tabChanged not tab-loaded)
- Browser Whisper: Fixed CSP to allow CDN loading (unsafe-eval + jsdelivr)
- Worker: Added error handling and logging for importScripts
- Voice Preferences: Multiple init paths with fallbacks
- Debug logging throughout for troubleshooting

Changes:
- server.js: CSP allows unsafe-eval, cdn.jsdelivr.net in connectSrc
- voicePreferences.js: Correct event name, immediate init fallback
- whisperWorker.js: Try-catch on importScripts, better errors
- app.js: Enhanced preload error handling

This version should actually work - previous bugs were:
1. Wrong event name prevented TTS preview init
2. CSP blocked worker CDN loading
2026-03-31 16:04:25 +00:00
ifedan-ed
88036a45c4 v15.1: CRITICAL FIX - TTS Preview + Browser Whisper actually working now
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
ROOT CAUSES FOUND AND FIXED:
1. TTS Preview not working: voicePreferences.js listening for wrong event
   - Was: 'tab-loaded' (never dispatched)
   - Now: 'tabChanged' (correct event name used by app.js)
   - Added immediate init if page already loaded
   - Added 500ms delay for DOM readiness

2. Browser Whisper CDN blocked: CSP too restrictive
   - Added 'unsafe-eval' to scriptSrc (required by transformers.js)
   - Added cdn.jsdelivr.net to connectSrc (worker importScripts)
   - Added childSrc directive for worker script loading
   - Better error messages in worker

3. Worker loading errors: Now logged with specific reasons
   - importScripts wrapped in try-catch
   - Posts error message to main thread
   - Verifies transformers object exists after load

Testing:
- TTS Preview should now work when clicking Settings tab
- Browser Whisper should load from CDN (or show specific error)
- Console logs will show exact init sequence
2026-03-31 16:00:10 +00:00
ifedan-ed
ee3729eb57 v15: Fix TTS preview + Browser Whisper preload with extensive debugging
BREAKING FIXES:
- TTS Preview: Added event.preventDefault(), console logging, proper init check
- Browser Whisper: Complete console logging pipeline, error handling, progress tracking
- Voice Preferences: DOMContentLoaded fallback, explicit button click handlers
- Whisper Worker: Console logs at every step, better error messages

Debugging Features:
- Console logs show: button clicks, init events, progress updates, errors
- Progress tracking: [WhisperWorker] Progress: model.bin 47%
- Error messages: Specific failure reasons (not generic failures)
- Timeout warnings: 30s check for stuck downloads

Audio Backup Confirmed:
- Deletes immediately on successful transcription (line 621-624 app.js)
- NOT after 24 hours - 24h is server retention limit for failed transcriptions
- User was correct - this is working as designed

How to Debug:
1. Open DevTools → Console (F12)
2. Click button
3. Watch for [VoicePrefs] or [BrowserWhisper] logs
4. Check Network tab for actual downloads
5. Report what you see in console
2026-03-31 15:28:38 +00:00
ifedan-ed
9016af8fe2 Fix TTS preview + Browser Whisper preload, add comprehensive docs
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
Fixes:
- TTS preview: Better error handling, console logging, empty value check
- Browser Whisper: Add progress logging, 30s timeout warning, better UX
- Voice preferences: Clearer error messages

New Documentation:
- FEATURES_EXPLAINED.md: Complete guide to all v14 features
  - Audio backups explained (works every recording, not just on failure)
  - S3 integration setup guide (AWS, B2, MinIO)
  - Learning Hub default path explained (AI file picker starting folder)
  - Browser Whisper troubleshooting (download progress tracking)
  - TTS preview debugging steps
  - Comprehensive troubleshooting guide
2026-03-31 15:13:53 +00:00
ifedan-ed
364b686619 Add per-user voice preferences (STT model + TTS voice selection)
- NEW: User preferences for STT model and TTS voice
- Database: stt_model and tts_voice columns in users table
- UI: Voice Preferences section in Settings with dropdowns
- API: /api/user/preferences (GET/POST) + /preferences/options
- Transcribe: Respects user's STT model (Google, LiteLLM)
- TTS: Respects user's TTS voice (Google, LiteLLM, OpenAI, ElevenLabs)
- Preview: Test TTS voice before saving
- Available models/voices auto-detected from provider config
2026-03-31 14:47:00 +00:00
ifedan-ed
5c157cf6aa Add embeddings setup documentation 2026-03-31 14:37:46 +00:00
ifedan-ed
ea213d8baf Add Vertex AI embeddings + semantic search for Learning Hub
- New: Vector search with pgvector extension (cosine similarity)
- Embeddings: Vertex AI text-embedding-005 (768 dims, HIPAA-eligible)
- 3 search modes: keyword, semantic, hybrid (best of both)
- Auto-generate embeddings on content create/update
- Admin endpoints: /api/admin/learning/embeddings/generate (backfill), /status
- User endpoints: /api/learning/search/semantic, /search/hybrid
- Falls back to OpenAI embeddings if Vertex not configured
- Supports LiteLLM proxy routing

Models tested:
- vertex_ai/text-embedding-005 (768 dims, English+code) 
- vertex_ai/gemini-embedding-001 (3072 dims, multilingual) 
- vertex_ai/text-multilingual-embedding-002 (768 dims) 
2026-03-31 14:36:49 +00:00
ifedan-ed
9e43e12cfc Update docker-compose to use v14 2026-03-31 14:21:25 +00:00
ifedan-ed
d181430d7a v14: Browser Whisper transcription (WebAssembly, client-side, HIPAA-safe) 2026-03-31 14:16:06 +00:00
Daniel Onyejesi
d1a7c97ecc Add browser-side Whisper transcription (local, zero network, HIPAA-safe)
- whisperWorker.js: Web Worker running @xenova/transformers Whisper in WASM
- browserWhisper.js: main-thread manager — audio→Float32 conversion, worker lifecycle
- transcribeAudio() checks BrowserWhisper.isEnabled() first, falls back to server
- Settings UI: enable/disable, model picker (tiny/base/small), pre-download button
- CSP: add wasm-unsafe-eval, cdn.jsdelivr.net, HuggingFace CDN domains
- Default: whisper-tiny.en (~39MB, ~2-3s per clip)
2026-03-31 07:30:17 -04:00
Daniel Onyejesi
0ce2735315 Fix Read aloud stop button: findReadButton now finds data-action=speak buttons
The button was always returning null because it searched for onclick=speakText
but all output cards use data-action="speak" data-target="id". Now checks
data-action first so the button correctly toggles to Stop during playback.
2026-03-30 21:37:00 -04:00
Daniel Onyejesi
f13eb05218 Emails: true markdown/Resend style — plain white, no card, clean type
TTS: prefix model with openai/ so LiteLLM routes correctly

Email: horizontal rules instead of card border, spacious padding,
wordmark + divider + body + divider + footer. Reads like a doc.
TTS: tts-1 becomes openai/tts-1 automatically unless already prefixed.
2026-03-30 20:53:11 -04:00
Daniel Onyejesi
58094f6298 Clean email templates (Linear/Resend style) + LiteLLM Gemini STT
Emails: white card, clean typography, dark button, no gradients.
Same minimal aesthetic as Linear/Resend/Notion emails.
Verify page responses also updated to match.
2026-03-30 20:51:11 -04:00
Daniel Onyejesi
61ed414785 Fix LiteLLM STT: use chat/completions with Gemini audio instead of broken /audio/transcriptions
LiteLLM /audio/transcriptions gives 'Unmapped provider' for Vertex AI Chirp.
The correct approach: use /v1/chat/completions with a Gemini model and send
audio as base64 input_audio content block — Gemini natively understands audio.
Set LITELLM_STT_MODEL to your Gemini model name (e.g. gemini-2.5-flash).
2026-03-30 19:52:58 -04:00
ifedan-ed
6b6bf728d5 v13: Increase JSON limit to 10MB, client-side size check for chart review
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
- Raise express.json limit from 1MB to 10MB — handles large chart reviews
  with many notes (50 full clinic notes ≈ 600KB, well within new limit)
- Client-side: warn user if payload >8MB, friendly toast if >30 notes
- Bump to v13.0.0
2026-03-30 22:41:17 +00:00
ifedan-ed
22d9a8ec29 Update package-lock.json
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
2026-03-30 20:39:11 +00:00
ifedan-ed
841fe0c264 Fix chart review: prompt selection by top-level type, include per-visit labs
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
- Bug 1: When user selected "Outpatient" review type but had any subspecialty
  visit cards filled in, the backend ignored the top-level type and switched to
  the subspecialty prompt. Fixed: top-level type dropdown is now definitive.
  Per-visit note types only control data formatting/labeling, not prompt selection.

- Bug 2: Labs entered in a visit card were silently dropped for outpatient and
  subspecialty visits (only ED visit labs were included). Fixed: per-visit labs
  now appear immediately after their visit content, labeled with the visit date.

- Improved lab labeling: visit labs are labeled "Labs from this visit (date)"
  and the separate labs section is labeled "ADDITIONAL LABS (not tied to a
  specific visit)" so the AI clearly distinguishes them.
2026-03-30 20:30:07 +00:00
ifedan-ed
ca645fe941 v12: LiteLLM voice support, Vertex AI, model discovery, APK crash fix
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
- LiteLLM: chat, TTS (tts-1), STT (whisper-1) via proxy
- Google Vertex AI: direct chat, Gemini STT, Google Cloud TTS
- Admin model management: discover/search/toggle/custom models
- TTS shows actual provider in toast (not hardcoded ElevenLabs)
- APK crash fix: proper PNG splash + mipmap icons
- Server-side audio backups with gzip compression
- Expandable AI correction viewer
- Zero-config browser speech recognition
- Bump to v12.0.0
2026-03-30 15:38:59 +00:00
ifedan-ed
1c23f2dc12 Show TTS provider in toast, support full LiteLLM model paths
- TTS response now includes X-TTS-Provider header (google-tts, litellm/model, elevenlabs)
- Frontend reads header and shows actual provider in toast instead of hardcoded "Adam/ElevenLabs"
- CORS exposes X-TTS-Provider header so frontend can access it
- Updated .env.example: clarify that LITELLM_TTS_MODEL and LITELLM_STT_MODEL
  can be either the model_name alias OR the full provider/model path depending
  on your LiteLLM config (important for BAA compliance routing)
2026-03-30 13:37:20 +00:00
ifedan-ed
567450b51e Fix LiteLLM STT default: use whisper-1 instead of vertex_ai/chirp
vertex_ai/chirp does not work via LiteLLM's audio transcription proxy.
Changed default LITELLM_STT_MODEL from vertex_ai/chirp to whisper-1.
Updated .env.example documentation to match.
2026-03-30 13:28:49 +00:00
Daniel Onyejesi
a2263d9530 Fix STT/TTS properly per LiteLLM docs
STT: Vertex AI Chirp not supported via LiteLLM proxy (confirmed by docs).
     Now uses Gemini directly (transcribeGoogle.js) — auto-detected when
     GOOGLE_VERTEX_PROJECT is set, fallback to AWS then OpenAI.

TTS: LiteLLM Vertex TTS DOES work but requires the model_list ALIAS
     (tts-1) not the underlying path (vertex_ai/text-to-speech).
     Also pass voice param — LiteLLM supports Google Cloud voice names.
     Auto-detected when LITELLM_API_BASE is set.
2026-03-30 07:28:47 -04:00
Daniel Onyejesi
b40dc5584b Fix STT: AWS Transcribe takes priority over LiteLLM in auto-detect
LiteLLM's atranscription has a routing bug with Vertex AI Chirp proxy.
AWS Transcribe is already configured and working. Auto-detect now prefers
AWS over LiteLLM. Use TRANSCRIBE_PROVIDER=litellm to force LiteLLM.
2026-03-29 22:38:06 -04:00
Daniel Onyejesi
b856a6c1da Fix STT/TTS model paths: use exact vertex_ai/ paths for LiteLLM routing
LiteLLM aliases (whisper-1, tts-1) don't resolve in audio endpoints —
only chat completions support alias routing. Use exact paths:
- STT default: vertex_ai/chirp (was whisper-1)
- TTS default: vertex_ai/text-to-speech (was tts-1)
Override via LITELLM_STT_MODEL / LITELLM_TTS_MODEL in .env.
2026-03-29 22:28:32 -04:00
Daniel Onyejesi
e2e7943dcb v10: TTS/STT axios fixes, better error logging, stop button
- TTS: switch to axios, drop voice param (configured in LiteLLM per model)
- STT: log full LiteLLM error body so 500s are diagnosable in logs
- TTS: same error detail logging
- Fix 'ElevenLabs unavailable' toast to generic 'TTS unavailable'
- Add red Stop button to encounter recording UI
2026-03-29 22:12:02 -04:00
Daniel Onyejesi
e6091c299f Fix TTS axios/Vertex, generic toast, add stop button to encounter
- TTS: switch from OpenAI SDK to axios (same fix as STT), drop voice
  param since it's configured inside LiteLLM per model
- Fix 'ElevenLabs unavailable' toast shown even when provider is LiteLLM
- Add dedicated red Stop button to encounter recording UI
2026-03-29 22:09:56 -04:00
Daniel Onyejesi
98fddca1e5 Fix LiteLLM STT: remove prompt/response_format unsupported by Vertex Chirp
Vertex AI Chirp via LiteLLM rejects/hangs when 'prompt' and
'response_format' are included — these are OpenAI Whisper-only params.
Send only file + model for LiteLLM/Chirp.
2026-03-29 21:54:12 -04:00
Daniel Onyejesi
0e6a853f86 Revert STT auto-detect: LiteLLM handles audio when LITELLM_API_BASE is set 2026-03-29 21:48:44 -04:00
Daniel Onyejesi
96dc40fd0b Fix STT auto-detection: don't route to LiteLLM unless LITELLM_STT_MODEL is set
Having LITELLM_API_BASE for AI text was auto-routing audio transcription
through LiteLLM even when the proxy has no Whisper model configured,
causing silent hangs. Now LiteLLM STT only activates when LITELLM_STT_MODEL
is explicitly set. Falls back correctly to AWS Transcribe when configured.
2026-03-29 21:42:24 -04:00
Daniel Onyejesi
ce7d0e749d Fix LiteLLM STT: use axios directly instead of OpenAI SDK
OpenAI SDK's audio.transcriptions.create() hangs with LiteLLM
(no timeout, SDK-level incompatibility with multipart handling).
Use axios + form-data directly with 120s timeout — same approach
as ElevenLabs TTS. Handles both {text:"..."} and plain string responses.
2026-03-29 21:32:30 -04:00
Daniel Onyejesi
f8d865a0a9 Bump to v10 — new tag forces server to pull updated image 2026-03-29 20:14:36 -04:00
Daniel Onyejesi
e513298f6a Fix: bump SW cache to v12, switch JS/CSS to network-first
Old pedscribe-v11 cache was serving stale admin.js to browsers
even after server updates. New cache name forces old SW to
deactivate and all clients to get fresh JS on next load.
Also switch JS/CSS from stale-while-revalidate to network-first
so code fixes are picked up immediately.
2026-03-29 20:02:34 -04:00
Daniel Onyejesi
ae3ec64c92 Fix model management: empty LiteLLM list, always reload panel, clear-all
- LITELLM_MODELS = [] — no hardcoded models, global selector now only
  shows what admin has actually added via Search API
- getAvailableModelsWithOverrides: for LiteLLM returns only custom list
- Remove toggle safety check — admin can disable any/all models freely
- Admin panel always reloads on tab open (was cached, showing stale data)
- Add 'Clear all models' button for LiteLLM to wipe and start fresh
- Add POST /config/models/clear-all endpoint
2026-03-29 19:32:09 -04:00
Daniel Onyejesi
65e0317ae6 Add LiteLLM STT and TTS support
- TRANSCRIBE_PROVIDER=litellm routes audio to LiteLLM /audio/transcriptions
- TTS_PROVIDER=litellm routes to LiteLLM /audio/speech
- Both auto-detect when LITELLM_API_BASE is set (no extra config needed)
- LITELLM_STT_MODEL (default: whisper-1), LITELLM_TTS_MODEL (default: tts-1)
- LITELLM_TTS_VOICE (default: alloy) — alloy/echo/fable/onyx/nova/shimmer
- ElevenLabs still works if ELEVENLABS_API_KEY is set and TTS_PROVIDER=elevenlabs
- Health endpoint now reports tts provider
2026-03-29 19:11:16 -04:00
Daniel Onyejesi
6bb062561f Fix admin model management: route ordering, LiteLLM built-ins, auto-select
- Root cause: PUT /config/:key(*) wildcard was registered before
  /config/models/toggle and /config/models/default, intercepting them
  and returning "value is required" (body had modelId not value)
- Fix: move all model-specific PUT routes before the wildcard
- LiteLLM: return empty built-in list with discovery hint (hardcoded
  models don't match user's proxy — must use Search API)
- After adding a discovered model: auto-select it in the default dropdown
- GET /config/models now returns defaultModel so dropdown pre-selects it
2026-03-29 18:42:09 -04:00
Daniel Onyejesi
29f1a9b860 v9.1: Add Google Vertex AI + LiteLLM support, admin model management panel
- Add Vertex AI provider (Gemini models via @google-cloud/vertexai SDK)
- Add LiteLLM proxy support (OpenAI-compatible, routes to any provider)
- Admin panel: model search/discover from provider API, enable/disable, custom models, set default
- New endpoints: /config/models/discover, /config/models/add-discovered, /config/models/default
- Updated models.js with VERTEX_MODELS and LITELLM_MODELS lists
- Updated health endpoint with vertex + litellm status
2026-03-29 10:32:45 -04:00
ifedan-ed
a1e5830192 Fix APK crash: replace XML splash with PNG, add real mipmap launcher icons
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
Root cause: TWA LauncherActivity.onCreate calls Bitmap.createBitmap on the
splash drawable — the XML layer-list with only a color fill had 0x0 intrinsic
dimensions, causing IllegalArgumentException: "width and height must be > 0".

Fixes:
- Replace splash.xml with splash.png (384x384 blue circle with P logo)
- Add proper PNG launcher icons at all 5 density buckets (mdpi through xxxhdpi)
- Change android:icon from @drawable to @mipmap for proper icon resolution
2026-03-29 11:07:10 +00:00
ifedan-ed
296dd1f8f1 Server-side audio backups with compression, viewable AI corrections
Audio Backups:
- New audio_backups table in PostgreSQL (bytea, gzip compressed)
- POST /api/audio-backups — upload with gzip compression (level 6)
- GET /api/audio-backups — list user's backups
- GET /api/audio-backups/:id/audio — download decompressed audio
- DELETE /api/audio-backups/:id — delete backup
- Auto-cleanup every hour (24h expiry)
- Frontend saves to server first, falls back to IndexedDB
- Settings shows source badge (server/local) per backup

AI Corrections:
- Corrections list is now expandable — click to view original vs corrected
- Shows red "Original" and green "Corrected to" sections
- Click arrow to expand/collapse each correction
- Date shown on each correction
2026-03-29 10:56:31 +00:00
ifedan-ed
3ff31868f7 Fix Android crash: use resource references for TWA colors instead of inline hex
The TWA LauncherActivity crashed with Resources$NotFoundException (0xffffffff)
because android:value with hex color strings is not supported by
androidbrowserhelper — it expects android:resource pointing to color resources.

- Created res/values/colors.xml with all app colors
- Changed AndroidManifest.xml to use android:resource="@color/..."
- Updated styles.xml to reference color resources
2026-03-29 10:47:27 +00:00
ifedan-ed
da81abcffc Security: remove auth debug logging that exposed emails and responses
Removed console.log statements in auth.js that logged email addresses
and auth API responses to browser console. Final cleanup for v9.
2026-03-29 10:41:35 +00:00
ifedan-ed
56d99e67b3 Remove admin model management panel — models use global selector instead
The admin model management (enable/disable, custom models, default override)
had persistent rendering issues. Removed the UI panel — models are managed
via the global model selector in the header, which works reliably. Backend
API endpoints for model config are retained for future use.
2026-03-29 02:30:29 +00:00
ifedan-ed
e58876ddd9 Zero-config speech: skip upload when no transcription API, use browser speech directly
- Add GET /api/transcribe/status endpoint — returns whether any server
  transcription provider (Whisper/AWS/Local) is configured
- Frontend checks status on login via checkTranscribeStatus()
- When no provider configured: recording stops instantly, keeps live
  Web Speech API text, shows friendly toast — no error, no upload wait
- Works in encounter, dictation, and SOAP tabs
- App now works fully out-of-the-box with just an AI provider key
2026-03-29 02:09:53 +00:00
ifedan-ed
fe632985c1 Fix: admin models loading, clear refine/instructions on New, bigger HPI areas, unique labels
- Admin models: reset modelsLoaded flag on error so retry works
- Admin default model: fix redundant fetch race condition
- clearTab: now clears refine inputs, instructions, and demographic fields
- Encounter/dictation clear buttons: also clear refine input
- SOAP: instructions textarea already cleared by clearTab (soap-instructions)
- Encounter HPI: bigger transcript (400px) and output (600px) text areas
- Unique label enforcement: 409 error if saving with duplicate label
2026-03-29 01:35:45 +00:00
ifedan-ed
32618032b0 Revert chunk size to 8KB — larger sizes cause AWS deserialization errors
Keep 8KB CHUNK_SIZE (proven stable) but replace 10ms setTimeout delay
with a microtask break every 16 chunks. This avoids the AWS SDK
"Deserialization error: inspect {error}.\$response" while still
eliminating the ~1.25s/MB artificial delay from the old 10ms sleep.
2026-03-29 01:21:43 +00:00
ifedan-ed
bc22f80e25 Optimize transcription speed: remove artificial delays, add timing
- AWS Transcribe: remove 10ms delay between chunks (was adding ~1.25s/MB),
  increase chunk size from 8KB to 32KB (AWS max per frame)
- Add detailed timing logs (ffmpeg, streaming, total) for diagnostics
- OpenAI Whisper: use response_format='text' for faster response parsing
- Frontend: show transcription time in toast, request 16kHz sample rate,
  increase bitrate to 32kbps Opus (better quality, still small files)
- Return duration in API response for all providers
2026-03-29 00:59:30 +00:00
ifedan-ed
92b1d25f19 Fix APK signing: use apksigner directly instead of broken r0adkll action
The r0adkll/sign-android-release@v1 hardcodes build-tools 29.0.3 which
isn't available. Now uses apksigner from the latest installed build-tools
directly with zipalign + sign + verify steps.
2026-03-29 00:51:42 +00:00
ifedan-ed
67362212f6 Fix APK build: add appcompat dependency for Theme.AppCompat 2026-03-29 00:46:51 +00:00
ifedan-ed
ed9c767300 Fix APK build: use Gradle setup action, generate proper wrapper
The gradlew stub and missing gradle-wrapper.jar caused CI build to fail.
Now uses gradle/actions/setup-gradle@v4 to install Gradle, then generates
wrapper before building. Also renames signed APK and uploads both signed
and unsigned to GitHub Releases.
2026-03-29 00:41:42 +00:00
ifedan-ed
1478ce7d86 Add SHA256 fingerprint to assetlinks.json for TWA domain verification 2026-03-29 00:32:58 +00:00
ifedan-ed
1ff0f9760d v9: APK hardening, service worker caching, admin model validation, Docker v9
- APK: Add WAKE_LOCK, BOOT_COMPLETED, ACCESS_NETWORK_STATE permissions
- APK: Disable allowBackup for medical data security
- APK: AudioRecordingService now acquires wake lock, has stop action in notification
- Serve /.well-known/assetlinks.json for TWA domain verification
- Service worker: cache app shell, stale-while-revalidate for assets, network-first for API
- Admin model management: validate model ID format, prevent built-in conflicts, audit toggle actions, prevent disabling all models
- Bump version to v9.0.0, Docker tag to v9
2026-03-28 23:53:39 +00:00
Daniel Onyejesi
4e5b6fed5a Add admin model management dashboard — enable/disable, custom models, default override
- Full model management UI in admin panel: toggle models on/off, add custom
  model IDs (any OpenRouter/Bedrock ID), set admin-configured default model
- /api/models now returns admin-set default model, frontend respects it
- Toggle switch CSS for clean enable/disable UX
- Backend already had the API endpoints, this adds the missing UI
2026-03-28 22:07:16 +00:00
Daniel Onyejesi
7661d4a147 v10: Local Whisper transcription, bigger text areas, flexible AI memory
- Add local Whisper (whisper.cpp / faster-whisper) as transcription provider
  Set TRANSCRIBE_PROVIDER=local with configurable model size and binary path
- Upgrade all refine/instruction inputs to resizable textareas across
  encounter, dictation, hospital course, chart review, well visit, sick visit
- Make AI memory injection flexible: physician preferences and corrections
  are now actively applied (not just "formatting reference"), while still
  overridable by current prompt instructions
2026-03-28 22:00:30 +00:00
Daniel Onyejesi
b498c18fce Set TWA default host to peds.danvics.com, simplify APK workflow 2026-03-28 21:12:25 +00:00
Daniel Onyejesi
3b7994c2c1 v9: Major feature update — audio backup, SOAP save, Dragon memory, S3 docs, CI/CD, APK
Phase 1 — Critical Fixes:
- Fix SOAP instructions not clearing on Clear button
- Show transcription provider (AWS/OpenAI) in UI toast
- Fix silent transcription failures in dictation and SOAP modules
- Add IndexedDB audio backup system (24hr retention, retry from Settings)
- Prevent duplicate encounter saves with idempotency keys
- Add Save/Load/New bar to SOAP note generator

Phase 2 — Features:
- Dragon-like AI memory: auto-track user corrections, inject into prompts
- Per-section template categories (SOAP, HPI, well visit, sick visit)
- Bigger textarea for SOAP instructions
- S3 document upload/management (AWS S3, Backblaze B2, MinIO compatible)
- Faster transcription via lower bitrate recording (16kbps opus)

Phase 3 — APK & CI/CD:
- GitHub Actions: Docker build+push on version tags
- GitHub Actions: TWA APK build for Obtainium auto-updates
- Android TWA project with foreground service for background recording
- Enhanced PWA manifest with shortcuts and maskable icons
2026-03-28 21:08:32 +00:00
Daniel Onyejesi
51cd366c96 Security fixes: remove SSO token from URL, add prompt boundaries
- OIDC callback now passes only ?sso=ok flag, token stays in
  httpOnly cookie (prevents token leaking to logs/referrer/history)
- Frontend auth.js uses cookie-based auth for SSO flow
- Add [PHYSICIAN TEMPLATES] boundary markers around physicianMemories
  in all 5 generation routes to mitigate prompt injection
- Consistent boundary format across wellVisit, sickVisit, hpi, soap,
  hospitalCourse
2026-03-25 19:25:49 -04:00
Daniel Onyejesi
8e509a7166 Remove Firefox speech notice, fix auth.js SSO flow race condition
- Remove Firefox speech recognition notice (not needed)
- Fix missing closing brace that made speech recognition unreachable
- Fix auth.js SSO token handling to prevent brief auth screen flash
2026-03-25 19:04:20 -04:00
Daniel Onyejesi
8e544ad5b9 Add OpenID Connect SSO + Firefox speech notice
OIDC/SSO:
- New /api/auth/oidc route with PKCE for secure authorization
- Supports Azure AD, Okta, Keycloak, PocketID, Google, any OIDC provider
- Admin configurable: issuer, client ID/secret, button label
- Option to disable local auth (force SSO only)
- Auto-creates users on first SSO login, links existing by email
- SSO button on login page, hidden until admin enables OIDC

Firefox:
- Show info toast on first recording that live preview requires
  Chrome/Edge; server-side transcription still works in all browsers
2026-03-25 18:54:44 -04:00
Daniel Onyejesi
ce4ef822ba Fix AWS Transcribe: reduce chunk size from 32KB to 8KB
AWS Transcribe rejects audio event frames over ~16KB with a
cryptic "Deserialization error" / "Your stream is too big" message
hidden inside the SDK error object. Reducing to 8KB per chunk
fixes both Standard and Medical Transcribe streaming.
2026-03-25 18:41:05 -04:00
Daniel Onyejesi
a36cd9a299 Improve Transcribe error diagnostics, add minimum audio check
- Log $response status/headers/body on deserialization errors
- Add 10ms delay between audio chunks to prevent stream overload
- Skip transcription if audio < 0.5s (too short for recognition)
- Cleaner error logging with dedicated logTranscribeError helper
2026-03-25 18:29:48 -04:00
Daniel Onyejesi
42b002eea8 Add detailed error logging for Transcribe Medical failures
Log error name, AWS metadata, and root cause to diagnose
the "non-retryable streaming request" error.
2026-03-25 18:24:37 -04:00
Daniel Onyejesi
57642bfc74 Add Medical→Standard fallback and better error logging for AWS Transcribe
When Medical Transcribe fails (wrong IAM permissions, region not
supported), automatically falls back to Standard Transcribe instead
of returning an error. Logs the specific failure reason.
2026-03-25 18:10:21 -04:00
Daniel Onyejesi
8ce40503d8 Fix missing error handling in nextcloud disconnect, update docker-compose to v8
- Add try-catch to /nextcloud/disconnect route (was crashing on DB errors)
- Update docker-compose.yml image tag from v7 to v8
- Remove unused SESSION_SECRET from .env.example
2026-03-25 17:53:48 -04:00
Daniel Onyejesi
2877cc5d6c Wire physician memories/templates into all AI generation routes
Previously only well-visit and sick-visit used saved physician
templates. Now HPI encounter, HPI dictation, SOAP, and hospital
course all fetch getUserMemoryContext() and pass physicianMemories
to the backend so the AI learns from saved templates/preferences.
2026-03-25 17:35:30 -04:00
Daniel Onyejesi
80085db579 v8.0.0: Fix speech recognition repeating text, enable AWS Transcribe Medical
- Add deduplication logic to prevent Chrome Speech API from repeating
  sentences during long recording sessions (all 4 recording modules)
- Enable AWS Transcribe Medical with PRIMARYCARE specialty in .env
- Bump version to 8.0.0
2026-03-25 17:24:28 -04:00
Daniel Onyejesi
e39cfc1c76 Add ffmpeg audio conversion fallback for AWS Transcribe
- transcribeAWS.js: convert browser WebM/Opus → PCM 16kHz mono via
  ffmpeg before sending to AWS Transcribe — PCM is unambiguous and
  most reliable; gracefully falls back to ogg-opus if ffmpeg absent
- Dockerfile: install ffmpeg (apk add ffmpeg) so Docker image works
  out of the box with AWS Transcribe
- README: document Amazon Transcribe setup, ffmpeg requirement,
  Transcribe Medical specialty options, and env vars reference
2026-03-25 20:35:24 +00:00
Daniel Onyejesi
14497b3270 Add Amazon Transcribe streaming (no S3) with Medical specialty support
- New src/utils/transcribeAWS.js: streams audio directly to AWS
  Transcribe without requiring an S3 bucket
- Supports AWS_TRANSCRIBE_MEDICAL=true for Transcribe Medical
  (better clinical accuracy: drug names, diagnoses, procedures)
- AWS_TRANSCRIBE_SPECIALTY configures specialty (default PRIMARYCARE)
- transcribe.js auto-selects AWS when AWS_BEDROCK_REGION is set,
  or can be forced with TRANSCRIBE_PROVIDER=aws|openai
- Falls back to OpenAI Whisper when AWS is not configured
- Add @aws-sdk/client-transcribe-streaming as optional dependency
- Update .env.example with transcription configuration docs
2026-03-25 20:26:10 +00:00
Daniel Onyejesi
e0757310c8 v7: fix speech recognition repetition, HTML injection, long-session guard
- Fix word repetition: use sessionFinals pattern so each browser SR
  session starts fresh; no overlap when recognition auto-restarts
- Fix HTML injection / '>' parse error: escape < > & in live transcript
  innerHTML before inserting speech recognition text
- Add 24 MB blob guard: fall back to live SR transcript if audio file
  is too large for Whisper API (long sessions)
- Bump version to 7.0.0, update docker-compose image tag to v7
2026-03-25 20:07:10 +00:00
248 changed files with 39220 additions and 21210 deletions

View file

@ -3,7 +3,6 @@
!.env.example !.env.example
.git .git
.gitignore .gitignore
.agent-config
node_modules node_modules
data/ data/
*.log *.log

View file

@ -0,0 +1,184 @@
name: Forgejo Android APK
on:
workflow_dispatch:
push:
branches:
- '**'
tags:
- 'v*'
jobs:
build:
name: Build signed APK
runs-on: forgejo-local
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up JDK 17
uses: https://github.com/actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Set up Node 20
uses: https://github.com/actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: mobile/package-lock.json
- name: Set up Android SDK
uses: https://github.com/android-actions/setup-android@v3
- name: Install Capacitor dependencies
working-directory: mobile
run: |
npm install --no-audit --no-fund
npx cap sync android
- name: Restore signing keystore
env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
test -n "$KEYSTORE_B64"
CLEAN_KEYSTORE_B64="${KEYSTORE_B64#ANDROID_KEYSTORE_BASE64=}"
printf '%s' "$CLEAN_KEYSTORE_B64" | tr -d '\r\n' | base64 -d > "$RUNNER_TEMP/pedscribe-release.jks"
test -s "$RUNNER_TEMP/pedscribe-release.jks"
- name: Build signed release APK
working-directory: mobile/android
env:
KS_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASS: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
./gradlew assembleRelease \
-Pandroid.injected.signing.store.file="$RUNNER_TEMP/pedscribe-release.jks" \
-Pandroid.injected.signing.store.password="$KS_PASS" \
-Pandroid.injected.signing.key.alias="$KEY_ALIAS" \
-Pandroid.injected.signing.key.password="$KEY_PASS" \
--no-daemon --stacktrace
- name: Check Google Play secret
id: play_publish
run: |
if [[ "$GITHUB_REF" != refs/tags/v* ]]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
elif [ -z "${GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64:-}" ]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64 }}
- name: Build signed release App Bundle
if: steps.play_publish.outputs.enabled == 'true'
working-directory: mobile/android
env:
KS_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASS: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
./gradlew bundleRelease \
-Pandroid.injected.signing.store.file="$RUNNER_TEMP/pedscribe-release.jks" \
-Pandroid.injected.signing.store.password="$KS_PASS" \
-Pandroid.injected.signing.key.alias="$KEY_ALIAS" \
-Pandroid.injected.signing.key.password="$KEY_PASS" \
--no-daemon --stacktrace
- name: Install fastlane
if: steps.play_publish.outputs.enabled == 'true'
working-directory: mobile/android
run: |
gem install bundler -N
bundle install
- name: Upload bundle to Google Play (internal track)
if: steps.play_publish.outputs.enabled == 'true'
working-directory: mobile/android
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64 }}
PLAY_TRACK: internal
run: |
test -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64"
CLEAN_PLAY_JSON_B64="${GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64#GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64=}"
printf '%s' "$CLEAN_PLAY_JSON_B64" | tr -d '\r\n' | base64 -d > fastlane/google-play-service-account.json
AAB=$(find app/build/outputs/bundle/release -name '*.aab' | head -1)
test -n "$AAB"
AAB_PATH="$AAB" bundle exec fastlane android publish_internal
rm -f fastlane/google-play-service-account.json
- name: Collect APK
run: |
mkdir -p artifacts
APK=$(find mobile/android/app/build/outputs/apk/release -name '*.apk' | head -1)
test -n "$APK"
cp "$APK" "artifacts/pedscribe-${GITHUB_REF_NAME:-manual}.apk"
- name: Upload APK artifact
uses: https://github.com/actions/upload-artifact@v3
with:
name: pedscribe-android-apk
path: artifacts/*.apk
retention-days: 30
- name: Publish Forgejo release
if: startsWith(github.ref, 'refs/tags/v')
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
TAG_NAME: ${{ github.ref_name }}
TARGET_COMMIT: ${{ github.sha }}
run: |
test -n "$FORGEJO_TOKEN"
API_URL="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
APK=$(find artifacts -name '*.apk' | head -1)
test -n "$APK"
node - <<'NODE'
const fs = require('fs');
fs.writeFileSync('release-payload.json', JSON.stringify({
tag_name: process.env.TAG_NAME,
target_commitish: process.env.TARGET_COMMIT,
name: process.env.TAG_NAME,
body: 'Signed Android APK for Obtainium updates.',
draft: false,
prerelease: false,
}));
NODE
status=$(curl -sS -o release.json -w '%{http_code}' \
-X POST "$API_URL/releases" \
-H "Authorization: token $FORGEJO_TOKEN" \
-H 'Content-Type: application/json' \
--data-binary @release-payload.json)
if [ "$status" = "409" ]; then
curl -fsS "$API_URL/releases/tags/$TAG_NAME" \
-H "Authorization: token $FORGEJO_TOKEN" > release.json
elif [ "$status" != "201" ]; then
cat release.json
exit 1
fi
RELEASE_ID=$(node -e "console.log(JSON.parse(require('fs').readFileSync('release.json', 'utf8')).id)")
ASSET_NAME=$(basename "$APK")
export ASSET_NAME
curl -fsS "$API_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $FORGEJO_TOKEN" > release-assets.json
EXISTING_ASSET_ID=$(node -e "const fs=require('fs'); const name=process.env.ASSET_NAME; const assets=JSON.parse(fs.readFileSync('release-assets.json','utf8')); const asset=assets.find((item)=>item.name===name); if (asset) console.log(asset.id);" )
if [ -n "$EXISTING_ASSET_ID" ]; then
curl -fsS -X DELETE "$API_URL/releases/$RELEASE_ID/assets/$EXISTING_ASSET_ID" \
-H "Authorization: token $FORGEJO_TOKEN"
fi
curl -fsS -X POST "$API_URL/releases/$RELEASE_ID/assets?name=$ASSET_NAME" \
-H "Authorization: token $FORGEJO_TOKEN" \
-F "attachment=@$APK" > release-asset.json

View file

@ -0,0 +1,45 @@
name: Forgejo Docker Build
on:
workflow_dispatch:
inputs:
push_image:
description: Push image to Forgejo container registry
required: false
default: 'true'
jobs:
build:
name: Build Docker image
runs-on: forgejo-local
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare compose env files
run: |
touch .env
- name: Validate Compose config
run: docker compose -f docker-compose.yml config >/tmp/ped-ai-compose.yml
- name: Build compose service
run: docker compose -f docker-compose.yml build pediatric-scribe
- name: Tag image
run: |
IMAGE="git.danvics.com/danvics/pediatric-ai-scribe-v3"
SHORT_SHA=$(git rev-parse --short HEAD)
docker tag ped-ai-local:latest "$IMAGE:$SHORT_SHA"
docker tag ped-ai-local:latest "$IMAGE:latest"
- name: Push image to Forgejo registry
if: ${{ github.event.inputs.push_image != 'false' }}
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
run: |
IMAGE="git.danvics.com/danvics/pediatric-ai-scribe-v3"
SHORT_SHA=$(git rev-parse --short HEAD)
echo "$FORGEJO_TOKEN" | docker login git.danvics.com -u danvics --password-stdin
docker push "$IMAGE:$SHORT_SHA"
docker push "$IMAGE:latest"

16
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,16 @@
## Summary
-
-
-
## Type of change
- [ ] refactor
- [ ] feature
- [ ] fix
- [ ] docs
## Verification
What did you run locally? (e.g. `npm test`, `npm run typecheck`, manual smoke)
## Linked issues
Closes #

View file

@ -21,6 +21,7 @@ permissions:
jobs: jobs:
build: build:
if: ${{ github.server_url == 'https://github.com' }}
name: Build signed APK name: Build signed APK
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View file

@ -31,7 +31,7 @@ permissions:
jobs: jobs:
version: version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'Release v') && !contains(github.event.head_commit.message, '[skip ci]')" if: "github.server_url == 'https://github.com' && !contains(github.event.head_commit.message, 'Release v') && !contains(github.event.head_commit.message, '[skip ci]')"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4

View file

@ -14,6 +14,7 @@ env:
jobs: jobs:
build-apk: build-apk:
if: ${{ github.server_url == 'https://github.com' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write

34
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: CI
# Runs root app tests on every PR and push to main.
on:
pull_request:
push:
branches: [main]
# Cancel superseded runs on the same ref to save minutes.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Root app tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install
run: npm install
- name: Unit tests
run: npm test

View file

@ -24,6 +24,7 @@ env:
jobs: jobs:
build: build:
if: ${{ github.server_url == 'https://github.com' }}
# Build one variant per matrix entry, push by digest only. # Build one variant per matrix entry, push by digest only.
name: Build ${{ matrix.platform }} name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
@ -80,6 +81,7 @@ jobs:
retention-days: 1 retention-days: 1
merge: merge:
if: ${{ github.server_url == 'https://github.com' }}
# Combine the two single-platform digests into one multi-arch manifest # Combine the two single-platform digests into one multi-arch manifest
# published under the real tags (vX.Y.Z and latest). # published under the real tags (vX.Y.Z and latest).
name: Merge manifests name: Merge manifests

30
.github/workflows/security.yml vendored Normal file
View file

@ -0,0 +1,30 @@
name: Security audit
# Weekly npm audit at high+ severity for the root app. Reports to the job summary; does NOT fail the build
# (advisories appear constantly and a red checkmark train would just get
# muted). Re-run on demand via workflow_dispatch.
on:
schedule:
- cron: '0 6 * * 1' # Mondays 06:00 UTC
workflow_dispatch:
jobs:
audit:
name: npm audit (high+)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Audit root app
run: |
echo '## Root app advisories' >> "$GITHUB_STEP_SUMMARY"
npm audit --audit-level=high --json > legacy-audit.json || true
node -e "const a=require('./legacy-audit.json');const m=a.metadata?.vulnerabilities||{};console.log('high:'+(m.high||0)+' critical:'+(m.critical||0));" >> "$GITHUB_STEP_SUMMARY"
continue-on-error: true

View file

@ -30,6 +30,7 @@ permissions:
jobs: jobs:
bump: bump:
if: ${{ github.server_url == 'https://github.com' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout

3
.gitignore vendored
View file

@ -38,3 +38,6 @@ e2e/test-results/
e2e/playwright-report/ e2e/playwright-report/
.codex .codex
.firecrawl/
# Refactored test stack stays local for now

View file

@ -28,7 +28,7 @@ or Actions tab → **Version bump & release** → Run workflow → pick bump typ
| Workflow | Output | | Workflow | Output |
|---|---| |---|---|
| `android-release.yml` | signed APK on GitHub release, `make_latest=true` | | `.forgejo/workflows/android-apk.yml` | signed APK on Forgejo release (`pedscribe-<tag>.apk`), optional Google Play internal track upload |
| `docker-publish.yml` | `danielonyejesi/pediatric-ai-scribe-v3:{version,latest}` on Docker Hub (amd64) | | `docker-publish.yml` | `danielonyejesi/pediatric-ai-scribe-v3:{version,latest}` on Docker Hub (amd64) |
## Local dev ## Local dev

View file

@ -8,7 +8,7 @@ FROM node:20-alpine
WORKDIR /app WORKDIR /app
# ffmpeg: audio conversion for AWS Transcribe (WebM → PCM) # ffmpeg: audio conversion for AWS Transcribe (WebM → PCM)
# curl: download Whisper models for browser-based transcription # curl: HTTP helper used by the OpenBao entrypoint and health/debug tooling
# jq: JSON parsing for the entrypoint's OpenBao secret-fetch step # jq: JSON parsing for the entrypoint's OpenBao secret-fetch step
RUN apk add --no-cache ffmpeg curl jq RUN apk add --no-cache ffmpeg curl jq
@ -30,22 +30,6 @@ RUN chmod +x /app/docker-entrypoint.sh
RUN mkdir -p /app/data/logs RUN mkdir -p /app/data/logs
# Download Browser Whisper (COMPLETE self-hosting - zero CDN dependencies)
# Library + Models all bundled and served from our server
RUN mkdir -p /app/public/models/Xenova/whisper-tiny.en/onnx && \
cd /app/public/models && \
echo "Downloading transformers.js library (worker-compatible build)..." && \
curl -sL -o transformers.min.js https://cdn.jsdelivr.net/npm/@xenova/transformers@2.0.0/dist/transformers.min.js && \
cd Xenova/whisper-tiny.en && \
echo "Downloading Whisper model files..." && \
curl -sL -o config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/config.json && \
curl -sL -o tokenizer.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/tokenizer.json && \
curl -sL -o preprocessor_config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/preprocessor_config.json && \
curl -sL -o generation_config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/generation_config.json && \
curl -sL -o onnx/encoder_model_quantized.onnx https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/encoder_model_quantized.onnx && \
curl -sL -o onnx/decoder_model_merged_quantized.onnx https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/decoder_model_merged_quantized.onnx && \
echo "✅ Browser Whisper: 100% self-hosted (library: 760KB, models: 42MB)"
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
@ -56,4 +40,3 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
# unset, so legacy .env-only deployments continue to work unchanged. # unset, so legacy .env-only deployments continue to work unchanged.
ENTRYPOINT ["/app/docker-entrypoint.sh"] ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["node", "server.js"] CMD ["node", "server.js"]

430
README.md
View file

@ -1,78 +1,103 @@
# Pediatric AI Scribe v6 # Ped-AI
AI-powered clinical documentation platform for pediatric medicine. Generates HPIs, hospital courses, chart reviews, SOAP notes, well/sick visit notes, and developmental milestone assessments from voice recordings or dictation. Ped-AI is a pediatric clinical documentation, education, and bedside decision-support app. This fork has moved well beyond the original scribe app: it now combines encounter documentation, clinical workflows, Learning Hub CMS, admin controls, MCP-backed clinical assistant integration, Redis-backed operational state, and hardened deployment defaults.
## Features The app runs as an authenticated Express/Postgres service with a browser frontend and optional integrations for LiteLLM, Vertex/Gemini, AWS, OpenAI-compatible APIs, Nextcloud WebDAV, S3-compatible storage, OpenBao, Redis, OIDC, TOTP, and Cloudflare Turnstile.
## Current Scope
### Clinical Documentation ### Clinical Documentation
- **Live Encounter** — record doctor-patient conversations, AI generates structured OLDCARTS HPI
- **Voice Dictation** — dictate narrative, AI cleans and restructures
- **Hospital Course** — paste progress notes, generates prose, day-by-day, organ-system (ICU), or psych format
- **Chart Review / Precharting** — summarize outpatient, subspecialty, and ED notes
- **SOAP Notes** — full SOAP or subjective-only from dictation
- **Well Visit** — AAP 2025 Bright Futures periodicity with vaccines, screenings, billing codes, SSHADESS (12+), milestones
- **Sick Visit** — quick documentation with auto-suggested ROS and PE from chief complaint
- **Developmental Milestones** — AAP/Nelson tracker (birth-11y) with narrative/structured/summary output
### AI & Speech - Live encounter capture with structured pediatric HPI generation.
- **5 AI Providers** — OpenRouter, AWS Bedrock, Azure OpenAI, Google Vertex AI, LiteLLM - Dictation cleanup for narrative notes.
- **5 STT Providers** — Google Gemini, Amazon Transcribe (Medical), OpenAI Whisper, Local Whisper, LiteLLM - SOAP, sick visit, well visit, hospital course, chart review, precharting, and ED encounter workflows.
- **3 TTS Providers** — Google Cloud TTS, LiteLLM (OpenAI), ElevenLabs - Parent-facing education handouts generated from clinician notes, with diagnosis, medication, emergency-care guidance, and preferred-language support.
- **Browser Whisper** — fully offline in-browser transcription via WebAssembly (HIPAA-safe) - Pediatric developmental milestone tooling.
- **Per-tab model selector** — choose fast vs. smart vs. premium models per task - Templates, physician memory, and per-tab model overrides.
- **Physician memory system** — Dragon-like learning from your corrections - Server-side speech-to-text routing through configured providers.
### Bedside Tools
- Pediatric calculators and emergency dosing helpers.
- PE guide and clinical reference content.
- Vaccines, catch-up schedules, growth/vitals, bilirubin, BSA, GCS, equipment, and resuscitation helpers.
- Mobile-friendly PWA layout for bedside use.
- Per-user phone extension and pager directory with soft-delete, search, ZIP export, and JSON/ZIP import for handoff between users.
### Learning Hub ### Learning Hub
- **Content Management** — articles, clinical pearls, quizzes, presentations
- **AI Content Generation** — generate from topics, uploaded PDFs, or Nextcloud files
- **Marp Presentations** — slide editor with preview and PPTX export
- **Semantic Search** — vector-based search via pgvector embeddings
- **Quiz System** — MCQ, multi-select, true/false with scoring and progress tracking
### Platform - CMS for articles, clinical pearls, quizzes, and presentations.
- **Multi-user with roles** — admin, moderator, user - Tiptap article editor, quiz builder, category management, and draft/publish flow.
- **OIDC/SSO** — Azure AD, Okta, Keycloak, PocketID, Google - AI-assisted content generation from topic text, uploaded files, or connected Nextcloud WebDAV files.
- **2FA** — TOTP-based two-factor authentication - Marp slide editing with preview and PPTX export.
- **Cloudflare Turnstile** — bot protection on login, register, password reset - Keyword, semantic, and hybrid search using Postgres/pgvector where configured.
- **Email verification** — with customizable templates
- **Nextcloud integration** — WebDAV export
- **S3 Document Storage** — AWS S3, Backblaze B2, MinIO
- **PWA** — installable, works on mobile
- **Admin Panel** — user management, settings, prompt editor, model configuration, logs
--- ### Clinical Assistant
- Optional MCP-backed clinical assistant integration.
- Prompt suggestions backed by Redis operational cache.
- No clinical answer response caching.
- Designed to retrieve from indexed clinical material while keeping provider selection explicit.
### Admin And Security
- Local auth, role-based access, TOTP 2FA, OIDC/SSO, email verification, and optional Turnstile.
- Admin panel for users, settings, prompts, models, logs, and Learning Hub content.
- Audit, API, access, and client-error logs with redaction hardening.
- OpenBao secret loading support at container startup.
- S3-compatible document storage support.
## Removed Browser STT
Browser Whisper has been removed from the runtime. The app should not ship browser Whisper workers, browser-local Whisper model downloads, Transformers.js browser STT, or Browser Whisper setup docs.
Speech-to-text is handled server-side through configured providers such as Google/Gemini, AWS Transcribe, LiteLLM, or OpenAI Whisper. Browser-native Web Speech remains gated behind an explicit user setting when present in the browser.
## Quick Start ## Quick Start
### 1. Configure
```bash ```bash
cp .env.example .env cp .env.example .env
docker compose up -d --build
``` ```
Edit `.env` — at minimum set: The default compose exposes the app on `127.0.0.1:3552` and starts:
```env - `pediatric-ai-scribe` for the Node app.
AI_PROVIDER=litellm # or openrouter, bedrock, azure, vertex - `pedscribe-db` for Postgres with pgvector.
LITELLM_API_BASE=https://your-litellm.example.com - `ped-ai-redis` for operational Redis state.
LITELLM_API_KEY=sk-...
OPENAI_API_KEY=sk-... # for Whisper transcription (if not using LiteLLM STT) Health check:
JWT_SECRET=<64-char random> # openssl rand -hex 32
DB_PASSWORD=<strong password>
APP_URL=https://your-domain.com
```
### 2. Start
```bash ```bash
docker compose up -d curl -fsS http://127.0.0.1:3552/api/health
``` ```
App runs on **port 3552**. First user to register becomes admin. Prometheus metrics are exposed at `GET /metrics` with the `ped_ai_` metric prefix.
### 3. Admin CLI The first registered user becomes an admin unless registration has already been configured differently.
## Core Environment
Set real values in `.env` before production use.
```env
APP_URL=https://your-domain.example
JWT_SECRET=<64-char-random-secret>
DB_PASSWORD=<strong-database-password>
AI_PROVIDER=litellm
LITELLM_API_BASE=https://your-litellm.example/v1
LITELLM_API_KEY=<key>
TRANSCRIBE_PROVIDER=litellm
LITELLM_STT_MODEL=whisper-1
REDIS_URL=redis://ped-ai-redis:6379
```
Supported text AI providers include LiteLLM, OpenRouter, AWS Bedrock, Azure OpenAI, and Google Vertex AI. Supported STT routing includes Google/Gemini, AWS Transcribe, OpenAI Whisper, and LiteLLM. Supported TTS routing includes Google Cloud TTS, LiteLLM/OpenAI-compatible audio, and ElevenLabs where configured.
## Admin CLI
```bash ```bash
docker exec pediatric-ai-scribe node admin-cli.js list-users docker exec pediatric-ai-scribe node admin-cli.js list-users
@ -83,293 +108,70 @@ docker exec pediatric-ai-scribe node admin-cli.js toggle-registration
docker exec pediatric-ai-scribe node admin-cli.js stats docker exec pediatric-ai-scribe node admin-cli.js stats
``` ```
--- ## Maintenance
## AI Provider Configuration The app checks Postgres collation drift on startup and can reindex text indexes after image or OS-library changes.
Switch providers by setting `AI_PROVIDER` in `.env`. No code changes needed.
| Provider | HIPAA | Config |
|----------|-------|--------|
| **LiteLLM** | Depends on backend | `LITELLM_API_BASE`, `LITELLM_API_KEY` |
| **AWS Bedrock** | Yes (with BAA) | `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` |
| **Azure OpenAI** | Yes (with BAA) | `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_DEPLOYMENT_NAME` |
| **Google Vertex AI** | Yes (with BAA) | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_VERTEX_LOCATION` |
| **OpenRouter** | No | `OPENROUTER_API_KEY` |
---
## Transcription (Speech-to-Text)
Set `TRANSCRIBE_PROVIDER` or let the app auto-detect.
| Provider | HIPAA | Config |
|----------|-------|--------|
| **Google Gemini** | Yes | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_STT_MODEL` |
| **Amazon Transcribe** | Yes | AWS creds + `TRANSCRIBE_PROVIDER=aws` |
| **Amazon Transcribe Medical** | Yes | `AWS_TRANSCRIBE_MEDICAL=true`, `AWS_TRANSCRIBE_SPECIALTY=PRIMARYCARE` |
| **Local Whisper** | Yes (offline) | `TRANSCRIBE_PROVIDER=local`, `WHISPER_BINARY`, `WHISPER_MODEL_SIZE` |
| **OpenAI Whisper** | No | `OPENAI_API_KEY` |
| **LiteLLM** | Depends | `TRANSCRIBE_PROVIDER=litellm`, `LITELLM_STT_MODEL` |
| **Browser Whisper** | Yes (client-side) | No config needed — toggle in user settings |
---
## Text-to-Speech
| Provider | HIPAA | Config |
|----------|-------|--------|
| **Google Cloud TTS** | Yes | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_TTS_VOICE` |
| **LiteLLM** | Depends | `LITELLM_TTS_MODEL`, `LITELLM_TTS_VOICE` |
| **ElevenLabs** | No | `ELEVENLABS_API_KEY` |
---
## OpenID Connect / SSO
Supports Azure AD, Okta, Keycloak, PocketID, Google, and any OIDC-compliant provider.
1. Register callback URL: `https://your-domain.com/api/auth/oidc/callback`
2. Admin Panel > Settings > Configure OIDC (Issuer URL, Client ID, Client Secret)
3. Users are auto-created and linked by email on first SSO login
See [docs/openid-setup.md](docs/openid-setup.md) for provider-specific guides.
---
## Cloudflare Turnstile (Bot Protection)
Optional CAPTCHA on login, registration, and password reset forms.
```env
TURNSTILE_SITE_KEY=0x4AAA...
TURNSTILE_SECRET_KEY=0x4AAA...
```
---
## Email
Without SMTP, email verification is skipped and users are auto-verified.
```env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=noreply@yourdomain.com
```
---
## Maintenance CLI
After a Postgres image upgrade (major version bump or silent base-layer change),
btree indexes on text columns can become inconsistent with the new ICU/glibc
library. The app auto-detects this at startup and reindexes on drift, but you
can also trigger it manually:
```bash ```bash
# Health check — no writes
docker exec pediatric-ai-scribe npm run maint:check docker exec pediatric-ai-scribe npm run maint:check
# Rebuild all indexes + refresh collation + ANALYZE
docker exec pediatric-ai-scribe npm run maint:reindex docker exec pediatric-ai-scribe npm run maint:reindex
``` ```
Run `maint:reindex` any time after: Run the reindex command after major Postgres image changes, restoring a dump from another distro, or seeing lookup behavior that suggests collation/index drift.
- Upgrading the Postgres image (major or minor)
- Restoring from a dump created on a different Linux distro
- Seeing "invalid credentials" on credentials you know are correct
- Seeing `0 rows` returned from a lookup that should match
The reindex takes seconds on a small DB and a minute or two on larger ones.
Safe to run while the app is serving traffic, though queries may slow briefly.
---
## Docker Hub
```bash
docker pull danielonyejesi/pediatric-ai-scribe-v3:latest
```
Minimal compose without building:
```yaml
services:
app:
image: danielonyejesi/pediatric-ai-scribe-v3:latest
ports:
- "3552:3000"
env_file: .env
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: pedscribe
POSTGRES_USER: pedscribe
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pedscribe"]
interval: 10s
retries: 5
volumes:
pgdata:
```
---
## HIPAA Notice
This application processes data through third-party AI APIs.
- All connections use HTTPS/TLS
- Authentication required for all AI endpoints
- 2FA and SSO available
- Cloudflare Turnstile bot protection
- **AWS Bedrock**, **Azure OpenAI**, and **Google Vertex AI** offer BAAs
- **OpenRouter** and **ElevenLabs** do NOT offer BAAs
- **Browser Whisper** and **Local Whisper** keep audio fully private
**Do not use real PHI without executed BAAs with all providers in your deployment.**
---
## Documentation
See [docs/](docs/) for the full documentation set.
### Application logic — start here if you're new to the codebase
[**docs/logic/**](docs/logic/) is a deep, dev-friendly walkthrough of how each
part of the app actually works. ~8,300 lines of "how it works and why" — read
the index first to know what's there:
- [docs/logic/README.md](docs/logic/README.md) — index + recommended reading order
- [docs/logic/architecture.md](docs/logic/architecture.md) — frontend IIFE pattern, lazy tab loading, backend route convention, schema, encryption, sacred zones
- [docs/logic/clinical-notes.md](docs/logic/clinical-notes.md) — every note tab (HPI, dictation, sick, well, SOAP, hospital, chart, notes) with the shared record→generate→save lifecycle
- [docs/logic/ed-encounters.md](docs/logic/ed-encounters.md) — multi-stage ED notes, per-stage don't-miss, consolidate→MDM finalize. Worked example of how a clinical workflow is composed in this codebase.
- [docs/logic/bedside-and-calculators.md](docs/logic/bedside-and-calculators.md) — Bedside emergencies module (ES-module pocket of the frontend), pediatric calculators, PE Guide, suture selector. Lists every clinical formula that must NOT be modified without test vectors.
- [docs/logic/ai-and-voice.md](docs/logic/ai-and-voice.md) — `callAI` 5-provider routing, prompt centralization with DB overrides, `wrapUserText`+`INJECTION_GUARD`, server STT routing, browser Whisper, the helper trio (refine/billing/don't-miss).
- [docs/logic/auth-admin-learning.md](docs/logic/auth-admin-learning.md) — local + OIDC auth, 2FA, sessions, OpenBao secret loading, Admin panel, Learning Hub.
### Operational + reference
- [Architecture Overview](docs/architecture.md) — high-level (the deep version is in docs/logic/architecture.md)
- [API Reference](docs/api-reference.md)
- [Database Schema](docs/database.md)
- [Authentication & Security](docs/authentication.md)
- [AI Providers & Models](docs/ai-providers.md)
- [Speech (STT/TTS)](docs/speech.md)
- [Learning Hub & CMS](docs/learning-hub.md)
- [Configuration Reference](docs/configuration.md)
- [Deployment Guide](docs/deployment.md)
- [Developer Guide (short)](docs/developer-guide.md)
- [Developer Guide (extended)](docs/developer-guide-extended.md)
- [Browser Whisper Setup](docs/browser-whisper-setup.md) · [Troubleshooting](docs/browser-whisper-troubleshooting.md)
- [Embeddings Setup](docs/embeddings-setup.md)
- [OpenID Connect Setup](docs/openid-setup.md)
- [Transcription Options](docs/transcription-options.md)
- [Features Explained](docs/features-explained.md)
- [Improvement Roadmap](docs/improvements.md)
---
## Development
```bash
npm install
cp .env.example .env # edit with your keys
# Requires PostgreSQL with pgvector
node server.js
```
---
## Testing ## Testing
Two layers, both zero-config after the initial setup. Run the Node test suite:
### Unit tests — pure dose math (Node built-in)
```bash ```bash
npm test npm test
``` ```
Runs `node --test test/` against `public/js/calc-math.js` — pure functions for Run syntax checks for touched files when doing focused backend work:
APLS / Best Guess weight, Parkland, Holliday-Segar 4-2-1, PRAM, Westley,
epi (anaphylaxis vs arrest vs NRP, different concentrations), RSI drugs,
min SBP, ETT sizing, Lund-Browder TBSA. **36 assertions, no dependencies.**
### End-to-end tests — Playwright smoke suite
Runs a headless Chromium against the live app. **128 tests** covering every
calculator tab, every Bedside sub-pill + widget, auth-gated pages (encounter,
well visit, charts, vaccines, catch-up, learning hub, dictation, settings,
FAQ), at **both desktop and mobile (Pixel 5) viewports**.
```bash ```bash
# First-time setup: spin up the auth-less test container (port 3553) node --check server.js
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d pediatric-scribe-e2e node --check src/routes/transcribe.js
```
# Then run the full suite (runs inside an official Playwright container) Run the Playwright smoke suite against the e2e compose stack:
```bash
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d pediatric-scribe-e2e
npm run e2e npm run e2e
``` ```
The runner script (`scripts/e2e.sh`) uses `mcr.microsoft.com/playwright` so you ## Deployment Notes
don't need Node or browsers on the host.
**Test environment:** - Put the app behind HTTPS before clinical use.
- Use only AI/STT/TTS providers covered by your BAA and data-processing requirements.
- Configure OIDC/SSO and 2FA for production users.
- Keep `JWT_SECRET`, database credentials, provider keys, S3 keys, SMTP credentials, and OpenBao tokens out of git.
- Treat logs as sensitive operational data even with redaction enabled.
- Use the Caddy/reverse-proxy layer to expose only intended public routes.
- `pediatric-ai-scribe` (port 3552) — your normal app ## Documentation
- `pediatric-ai-scribe-e2e` (port 3553) — identical image, but with
`TURNSTILE_SECRET_KEY=""` and `SMTP_HOST=""` so Playwright can log in
without a bot challenge. Shares the same Postgres + pgdata volume.
- Test user: `e2e-user@ped-ai.test` (auto-verified on first register)
- Harness page: `public/e2e-harness.html` loads the calculators component
without the auth wall for smoke tests that don't need a logged-in session.
**Viewing failures** — Playwright writes `e2e/test-results/<test-name>/` Primary references:
with:
- `test-failed-1.png` — screenshot at the point of failure - `docs/ARCHITECTURE.md` for the current system map and service boundaries.
- `trace.zip` — full action trace (replay with `npx playwright show-trace`) - `docs/DEVELOPMENT.md` for day-to-day code-change workflow.
- `error-context.md` — DOM snapshot and console logs - `docs/SCALING.md` for scaling priorities and readiness work.
- `docs/CLINICAL_ASSISTANT.md` for MCP-backed assistant behavior and safety rules.
- `docs/MODULE_CONVENTIONS.md` for CommonJS, ESM, globals, and rendering rules.
- `docs/architecture.md` for high-level architecture.
- `docs/api-reference.md` for API routes.
- `docs/authentication.md` for auth, OIDC, and security configuration.
- `docs/ai-providers.md` for model/provider setup.
- `docs/speech.md` for server-side STT/TTS setup.
- `docs/learning-hub.md` for the CMS and education workflow.
- `docs/configuration.md` for environment variables.
- `docs/deployment.md` for production deployment.
- `docs/mobile-build.md` for the Capacitor wrapper and app-store build notes.
- `docs/logic/README.md` for the deeper code walkthrough.
Everything but the specs and config is gitignored under `e2e/`. Some deep `docs/logic/` files still describe historical implementation details. Prefer runtime code and tests when documentation conflicts with current behavior.
**Files:** ## Clinical Safety
- `e2e/tests/bedside-smoke.spec.js` — 26 tests for the Bedside module Ped-AI is documentation and education support software. It does not replace clinical judgment, local policy, medication verification, or attending review. Validate generated notes, calculations, and recommendations before use in patient care.
- `e2e/tests/top-calculators.spec.js` — 27 tests for BP / BMI / Growth /
Bili / Vitals / BSA / Dose / Resus / GCS / Equipment
- `e2e/tests/auth-gated-smoke.spec.js` — 11 tests for the auth-gated tabs
- `e2e/playwright.config.js` — runs all the above under both `chromium`
(Desktop Chrome) and `mobile-chrome` (Pixel 5) projects
**Writing a new test:**
```js
const { test, expect } = require('@playwright/test');
test('my new smoke test', async ({ page }) => {
await page.goto('/e2e-harness.html'); // bypasses auth for calculators
await page.waitForFunction(() => window.__harnessReady === true);
await page.click('button.calc-nav-pill[data-calc="bedside"]');
await expect(page.locator('#calc-bedside')).toBeVisible();
});
```
For auth-gated routes, use the login fixture in `auth-gated-smoke.spec.js`
as a template — it caches the token at module scope so you don't hit the
login rate-limit.

View file

@ -8,16 +8,29 @@ services:
- .env - .env
environment: environment:
CLINICAL_ASSISTANT_MCP_URL: http://mcp:8000/mcp CLINICAL_ASSISTANT_MCP_URL: http://mcp:8000/mcp
REDIS_URL: redis://ped-ai-redis:6379
LOKI_URL: http://monitoring-loki:3100
LITELLM_API_BASE: http://litellm:4000
TTS_PROVIDER: litellm
LITELLM_TTS_MODEL: local-kokoro-tts
LITELLM_TTS_VOICE: sherpa/kokoro:am_adam
LITELLM_TTS_VOICES: sherpa/kokoro:am_adam,sherpa/kokoro:am_michael,sherpa/kokoro:af_bella,sherpa/kokoro:af_nicole,sherpa/kokoro:bf_emma,sherpa/kokoro:bm_lewis
CLINICAL_ASSISTANT_PROMPT_POOL_TARGET: 1000
volumes: volumes:
- scribe-logs:/app/data/logs - scribe-logs:/app/data/logs
- clinical-assistant-mcp-data:/app/mcp-data:ro
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
container_name: pediatric-ai-scribe container_name: pediatric-ai-scribe
networks: networks:
- default - default
- mcp-server_default - danvics_mcp
- danvics_monitoring
- danvics_speech
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"] test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s interval: 30s
@ -33,7 +46,7 @@ services:
environment: environment:
POSTGRES_DB: pedscribe POSTGRES_DB: pedscribe
POSTGRES_USER: pedscribe POSTGRES_USER: pedscribe
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD} POSTGRES_PASSWORD: ${DB_PASSWORD:-pedscribe}
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
restart: unless-stopped restart: unless-stopped
@ -45,10 +58,34 @@ services:
retries: 5 retries: 5
start_period: 10s start_period: 10s
redis:
image: redis:8-alpine
command: redis-server --appendonly yes
restart: unless-stopped
container_name: ped-ai-redis
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- default
- danvics_mcp
volumes: volumes:
pgdata: pgdata:
scribe-logs: scribe-logs:
redis-data:
clinical-assistant-mcp-data:
external: true
name: mcp-server_mcp-data
networks: networks:
mcp-server_default: danvics_mcp:
external: true
danvics_monitoring:
external: true
danvics_speech:
external: true external: true

90
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,90 @@
# Architecture
This document is the current high-level map for Ped-AI. It is intentionally shorter and more operational than the older deep-dive files under `docs/logic/`.
## System Shape
Ped-AI is a self-hosted Express application with a browser frontend, PostgreSQL storage, Redis operational state, LiteLLM model routing, and optional MCP-backed clinical retrieval.
| Area | Owner | Notes |
|---|---|---|
| Web app | Ped-AI | Auth, UI, clinical workflows, admin settings, notes, Learning Hub, bedside tools |
| Database | PostgreSQL | Users, sessions, settings, saved app data, audit/API/access logs |
| Operational cache | Redis | Prompt suggestions, lightweight state, queue groundwork; not clinical answer caching |
| Model gateway | LiteLLM | Text, speech, image, embedding model discovery and routing |
| Clinical retrieval | MCP service | Nextcloud access, indexing, search, rerank, source metadata |
| Reverse proxy | Caddy or equivalent | TLS and public routing |
## Request Flow
Normal app request:
```txt
browser
-> reverse proxy
-> Express middleware
-> auth/session check when protected
-> route handler
-> PostgreSQL/Redis/provider calls as needed
-> JSON or HTML fragment response
```
Clinical Assistant request:
```txt
browser
-> Ped-AI clinical assistant route
-> MCP semantic search for indexed clinical sources
-> Ped-AI builds grounded answer prompt
-> LiteLLM chat model
-> Ped-AI returns answer plus source metadata
-> browser renders markdown, citations, and source cards
```
Ped-AI owns the user workflow and rendering. MCP owns retrieval and indexed source metadata. LiteLLM owns model routing.
## Runtime Boundaries
| Boundary | Main Risk | Current Direction |
|---|---|---|
| Browser to Ped-AI | XSS, stale shell, session handling | Sanitized rendering, httpOnly cookie for web, cache busting |
| Ped-AI to PostgreSQL | schema drift, slow queries | migrations, maintenance checks, indexes where needed |
| Ped-AI to Redis | unavailable operational state | Redis is useful but should not hold required clinical answers |
| Ped-AI to LiteLLM | provider downtime, wrong model mode | metadata-based model discovery and timeouts |
| Ped-AI to MCP | retrieval latency/failure | explicit MCP client layer and graceful fallback messages |
| MCP to Nextcloud | stale indexed metadata | scanner/indexer updates source metadata over time |
## Source Of Truth
| Data | Source Of Truth |
|---|---|
| User accounts and sessions | Ped-AI PostgreSQL |
| Admin app settings | Ped-AI PostgreSQL `app_settings` |
| Clinical source documents | Nextcloud and MCP index |
| Clinical source title/path shown to users | MCP result metadata, especially indexed `file_path` |
| Clinical answer text | Generated per request; intentionally not cached |
| Model availability | LiteLLM metadata and configured fallbacks |
## Deployment Shape
Production usually runs:
```txt
Caddy/TLS
-> pediatric-ai-scribe container
-> pedscribe-db container
-> ped-ai-redis container
-> LiteLLM endpoint
-> MCP endpoint
```
The app should stay private behind the reverse proxy. Do not expose PostgreSQL, Redis, MCP internals, or provider keys publicly.
## Design Principles
- Keep Ped-AI stateless enough to run more than one app container.
- Keep clinical answer generation live and source-grounded; do not cache final clinical answers.
- Prefer model capability metadata over model-name regexes.
- Prefer indexed file names and paths over embedded PDF metadata for source titles.
- Keep renderer fixes narrow and tested because LLM markdown is messy.
- Keep old frontend globals working until the affected feature is intentionally converted to ESM.

View file

@ -0,0 +1,97 @@
# Clinical Assistant
The Clinical Assistant is a retrieval-grounded assistant for pediatric clinical reference questions. It is not the same as the app's note-generation/HPI workflow.
## Responsibilities
| Component | Responsibility |
|---|---|
| Browser UI | question input, source display, markdown/citation rendering, export |
| Ped-AI backend | settings, MCP search call, answer prompt construction, model call |
| MCP server | Nextcloud access, indexing, vector search, rerank, source metadata |
| LiteLLM | model routing and provider abstraction |
## Request Flow
```txt
User asks a question
-> browser posts to Ped-AI
-> Ped-AI calls MCP `nc_semantic_search`
-> MCP returns source excerpts and metadata
-> Ped-AI builds an answer prompt with source constraints
-> LiteLLM model returns answer text
-> browser renders answer and source cards
```
## Source Rules
- Prefer MCP `file_path` basename for displayed source titles when present.
- Do not relabel one source as another requested source.
- If the user names a source and retrieval does not return it, say that before using other sources.
- Use citations only for returned source numbers.
- Unknown citation numbers should remain plain text instead of being guessed.
## Table And Markdown Rendering
LLM output is not guaranteed to be valid markdown. The browser renderer defensively handles common problems:
- adjacent citation clusters,
- missing closing bracket in narrow citation cases,
- smashed bullet lists,
- inline headings,
- malformed pipe tables,
- bare source numbers in source/citation table columns,
- orphan markdown emphasis markers,
- code blocks that must not be modified.
Renderer fixes must be narrow. Do not add broad repairs that turn arbitrary clinical numbers into citations.
## Image Routing
Table lookup requests should stay in retrieval flow.
Examples that should use retrieval:
```txt
show me the table
show me Table 13.1
summarize the developmental table
```
Explicit visual creation/display requests can use image flow.
Examples:
```txt
create an infographic
generate a diagram
show me the image/figure
```
## Caching Policy
Clinical answer response caching is intentionally disabled. Redis can support prompt suggestions and operational metadata, but final answers should be generated from current retrieval context.
## Settings
Important settings include:
| Setting | Purpose |
|---|---|
| `clinical_assistant.chat_model` | Chat model used for answers |
| `clinical_assistant.image_model` | Image model used for explicit image generation |
| `clinical_assistant.search_limit` | Number of MCP results requested |
| `clinical_assistant.context_chars` | Context characters requested from MCP |
| `clinical_assistant.system_behavior` | Admin-editable assistant behavior guidance |
## Testing Priorities
Add or update tests when changing:
- citation rendering,
- source title cleanup,
- named-source provenance behavior,
- table rendering,
- image intent routing,
- MCP result normalization,
- model discovery or settings behavior.

103
docs/DEVELOPMENT.md Normal file
View file

@ -0,0 +1,103 @@
# Development
This is the practical guide for changing Ped-AI safely.
## Local Start
```bash
cp .env.example .env
docker compose up -d --build
curl -fsS http://127.0.0.1:3552/api/health
```
Run tests from the repository root:
```bash
npm test
```
Run a focused syntax check when touching backend entrypoints:
```bash
node --check server.js
node --check src/routes/clinicalAssistant.js
```
## Code Map
| Path | Purpose |
|---|---|
| `server.js` | Express entrypoint, middleware, static serving, route mounting |
| `src/routes/` | API route handlers |
| `src/utils/ai.js` | Text model routing through configured providers |
| `src/utils/clinicalAnswer.js` | Clinical Assistant answer prompt and source-grounding rules |
| `src/utils/clinicalRetrieval.js` | MCP result normalization and source title cleanup |
| `src/utils/clinicalMcpClient.js` | MCP streamable HTTP client/session handling |
| `src/utils/litellm.js` | LiteLLM API/admin header helpers |
| `src/db/database.js` | PostgreSQL pool and compatibility helpers |
| `public/js/app.js` | SPA shell, tab loading, shared browser actions |
| `public/js/admin.js` | Admin panel logic |
| `public/js/assistant/` | Clinical Assistant rendering, sources, images, export, API helpers |
| `public/js/learningHub/` | Newer modular Learning Hub frontend code |
| `test/` | Node test suite and frontend module regression tests |
## Change Workflow
1. Read the relevant route, utility, frontend module, and tests before editing.
2. Make the smallest correct change.
3. Add or update a regression test when changing clinical rendering, model routing, auth, settings, or source handling.
4. Run focused tests first if available.
5. Run `npm test` before deploy or commit.
6. Deploy with Docker only after tests pass.
7. Verify `/api/health` after deploy.
## Clinical Assistant Changes
Clinical Assistant changes should usually include tests because small rendering or prompt changes can affect clinical trust.
High-risk areas:
- citation linking,
- table rendering,
- source title cleanup,
- named-source provenance rules,
- image intent detection,
- MCP result normalization,
- provider/model selection.
When a real answer renders badly, save a de-identified example as a fixture or direct test input. Do not make broad global repairs that convert arbitrary numbers into citation links.
## Frontend Rendering Rules
Use `textContent` for plain text. Use `innerHTML` only for static templates, sanitized markdown, or HTML built entirely from escaped values.
Safe patterns:
```js
el.textContent = userText;
el.innerHTML = escapeHtml(userText).replace(/\n/g, '<br>');
el.innerHTML = sanitizeHtml(renderMarkdown(modelOutput));
```
Unsafe pattern:
```js
el.innerHTML = modelOutput;
```
If a dynamic value enters an HTML string, escape it at the point of insertion. If it is an attribute value, escape quotes too.
## Deployment Checks
After deployment:
```bash
curl -fsS http://127.0.0.1:3552/api/health
docker compose ps pediatric-scribe
```
If the browser still shows old frontend behavior, force-refresh or check the injected `BUILD_ID` asset query string.
## Documentation Expectations
Keep docs close to operational truth. If a behavior changes, update the most specific doc in the same change. Prefer short, current docs over long historical explanations.

View file

@ -0,0 +1,88 @@
# Module Conventions
Ped-AI currently uses mixed JavaScript module styles. This is intentional during incremental modernization.
## Current Convention
| Area | Module Style | Notes |
|---|---|---|
| Backend `server.js`, `src/**` | CommonJS | Use `require` and `module.exports` for now |
| New frontend modules | ESM | Use `import` and `export` |
| Older frontend files | Classic browser globals | Convert only when touching the feature intentionally |
| Dual browser/test files | Case-by-case | Keep classic style only when tests or browser globals require it |
Do not add root-level `"type": "module"` without a full backend migration plan. It would change how every `.js` file is interpreted by Node.
## CommonJS Example
```js
var express = require('express');
var router = express.Router();
module.exports = router;
```
## ESM Example
```js
import { escapeHtml } from './assistant/citations.js';
export function renderSourcesList(sources) {
return '';
}
```
## Frontend Modernization Path
1. New frontend code should be ESM where possible.
2. Existing globals can remain until that feature is refactored.
3. Keep browser script load order stable while refactoring.
4. Export pure helper functions so Node tests can import them.
5. Use `CustomEvent` or explicit imports instead of adding new global APIs when practical.
## Acceptable Globals
Globals are acceptable when they are part of the current shell contract.
Examples:
- `window.activateTab`,
- `window.getAuthHeaders`,
- shared UI helpers still consumed by legacy feature files.
Do not add new globals when an import or event would be clearer.
## Rendering And `innerHTML`
`innerHTML` is allowed only when one of these is true:
- the HTML is a static template controlled by the app,
- all dynamic values are escaped before insertion,
- the HTML has passed through the approved sanitizer,
- the content is a trusted app component fetched from `public/components/`.
Prefer `textContent` for plain text.
Unsafe:
```js
el.innerHTML = userText;
el.innerHTML = modelOutput;
```
Safer:
```js
el.textContent = userText;
el.innerHTML = escapeHtml(userText).replace(/\n/g, '<br>');
el.innerHTML = sanitizeHtml(renderMarkdown(modelOutput));
```
## Test Expectations
When converting a frontend file to ESM, add or update tests for:
- exported helper functions,
- expected globals still present if legacy code needs them,
- no browser-native `prompt`, `alert`, or `confirm`,
- no unescaped dynamic text inserted through `innerHTML`.

119
docs/SCALING.md Normal file
View file

@ -0,0 +1,119 @@
# Scaling
This document describes how Ped-AI should scale without becoming harder to debug or maintain.
## Current Scaling Model
Ped-AI is currently a single app container backed by PostgreSQL and Redis. That is acceptable for self-hosted use, but the code should keep moving toward a shape where multiple app containers can run safely.
```txt
reverse proxy
-> pediatric-ai-scribe replica 1
-> pediatric-ai-scribe replica 2
-> shared PostgreSQL
-> shared Redis
-> LiteLLM
-> MCP
```
## Horizontal Scaling Requirements
| Requirement | Why It Matters |
|---|---|
| Session state in PostgreSQL/Redis | Any app replica can handle the next request |
| No clinical state only in memory | Restarting or scaling containers should not lose required state |
| Shared uploads/storage if files grow | Local container disk does not scale across replicas |
| Idempotent migrations | Deploying more than one app container should not corrupt schema state |
| Request timeouts | Slow providers should not exhaust Node workers |
| Queue for slow jobs | Long work should not block interactive requests |
| Readiness endpoint | Load balancer should only send traffic to ready replicas |
## What Can Stay In Memory
Small process-local caches are acceptable when they are optional and short-lived.
Examples:
- settings cache with short TTL,
- provider model metadata cache,
- static configuration derived at boot.
Do not store required user workflow state only in memory if the action must survive restart or run across replicas.
## Redis Use
Redis is appropriate for:
- prompt suggestion pools,
- rate-limit coordination if needed,
- queues and job status,
- short-lived provider metadata,
- operational locks.
Redis should not be used for final clinical answer response caching. Clinical answers should be generated live from current retrieval context.
## Queue Candidates
Consider moving these to a queue when latency or concurrency becomes a problem:
- long transcription jobs,
- file import/export,
- Learning Hub AI generation from large files,
- image generation,
- bulk document operations,
- provider metadata refresh,
- long-running admin maintenance actions.
BullMQ with Redis is a natural fit if a queue is added.
## Readiness And Health
Keep `/api/health` fast and simple for liveness.
Add a separate readiness endpoint when scaling:
```txt
GET /api/ready
```
It should check:
- PostgreSQL query works,
- Redis ping works if Redis is required for this deployment,
- core settings can be read,
- MCP health is reachable if Clinical Assistant is enabled,
- LiteLLM metadata or configured model endpoint is reachable if AI features are enabled.
## Database Scaling
Priorities:
- confirm indexes on hot user/session/settings/log tables,
- keep migrations explicit and reversible where practical,
- monitor slow queries,
- cap admin log queries with safe limits,
- keep audit/log writes batched where possible,
- avoid long transactions around provider calls.
## Provider Scaling
LiteLLM and MCP can become the bottlenecks before Ped-AI does.
Track:
- LiteLLM request latency,
- LiteLLM error rate by model,
- MCP search latency,
- MCP timeout/error rate,
- queue depth if async jobs are added,
- Postgres connections,
- app container memory and event-loop delay.
## Scaling Order
1. Add request IDs across browser, Ped-AI, MCP, and LiteLLM calls.
2. Add `/api/ready` for dependency readiness.
3. Ensure sessions and settings are not process-local.
4. Add a queue for slow jobs if interactive requests block.
5. Run a second app replica behind the reverse proxy in a staging/test environment.
6. Add metrics and alerts around latency, errors, and resource saturation.

View file

@ -1,13 +1,18 @@
# AI providers # AI providers
All AI calls flow through `callAI(messages, options)` in `src/utils/ai.js`. All AI calls flow through `callAI(messages, options)` in `src/utils/ai.js`.
Provider is selected once at startup and is transparent to callers. Provider is selected at startup and is transparent to route handlers.
## Provider selection ## Provider selection
1. If `AI_PROVIDER` env var is set, use it. 1. If `AI_PROVIDER` is set, it chooses `bedrock`, `azure`, `vertex`,
2. Otherwise, check credentials in priority order: `litellm`, or `openrouter` explicitly.
`bedrock > azure > vertex > litellm > openrouter`. 2. If `AI_PROVIDER` is unset, `ai.js` initializes every configured client and
the last configured non-OpenRouter provider wins in current load order:
Bedrock → Azure → Vertex → LiteLLM. If none of those are configured,
OpenRouter is the default.
3. If the selected provider cannot initialize, the code falls back to
OpenRouter and surfaces an error if `OPENROUTER_API_KEY` is missing.
## Providers ## Providers
@ -15,7 +20,7 @@ Provider is selected once at startup and is transparent to callers.
- SDK: `@aws-sdk/client-bedrock-runtime`. - SDK: `@aws-sdk/client-bedrock-runtime`.
- Uses Bedrock **inference profiles** for newer models (cross-region routing). - Uses Bedrock **inference profiles** for newer models (cross-region routing).
- Model families: vendor model (Anthropic), Amazon Nova, Llama (Meta), Mistral, DeepSeek, Cohere. - Model families: Amazon Nova, Llama (Meta), Mistral, DeepSeek, Cohere, and other Bedrock-hosted families.
### Azure OpenAI (BAA-eligible) ### Azure OpenAI (BAA-eligible)
@ -27,7 +32,7 @@ Provider is selected once at startup and is transparent to callers.
- SDK: `@google-cloud/vertexai`. - SDK: `@google-cloud/vertexai`.
- Also serves STT (Gemini inline audio) and TTS (Vertex TTS endpoint). - Also serves STT (Gemini inline audio) and TTS (Vertex TTS endpoint).
- Families: Gemini 2.5 / 2.0, vendor model on Vertex (Anthropic via GCP), Llama. - Families: Gemini 2.5 / 2.0 and Llama.
### LiteLLM proxy (self-hosted) ### LiteLLM proxy (self-hosted)
@ -124,9 +129,11 @@ Applied to: `soap.js`, `hpi.js`, `refine.js`, `sickVisit.js`, `wellVisit.js`,
### Physician memories ### Physician memories
Saved corrections are injected into prompts as `[STYLE HINTS (low priority)]` Saved templates and prompt preferences are injected into prompts as
with 200-character snippets. The low-priority wording prevents smaller models `[STYLE HINTS (low priority)]` when they belong to AI-context categories. The
from hallucinating content from the correction examples into the current note. low-priority wording prevents smaller models from hallucinating content from a
stored template into the current note. `custom` memories and legacy
`correction_*` rows are not prompt context.
## API call logging ## API call logging

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,6 @@
# Architecture # Architecture
Self-hosted, single-tenant clinical documentation platform. Dockerized Node.js Self-hosted clinical documentation platform. Dockerized Node.js server, PostgreSQL, Redis, and vanilla-JS SPA. No build step on the frontend.
server + PostgreSQL + vanilla-JS SPA. No build step on the frontend.
## Stack ## Stack
@ -9,9 +8,11 @@ server + PostgreSQL + vanilla-JS SPA. No build step on the frontend.
|---|---| |---|---|
| Runtime | Node.js 20 (Alpine) + Express 4 | | Runtime | Node.js 20 (Alpine) + Express 4 |
| Database | PostgreSQL 16 with `pgvector` extension | | Database | PostgreSQL 16 with `pgvector` extension |
| Cache / state | Redis for operational cache, prompt suggestions, and queue groundwork |
| Frontend | Vanilla JavaScript SPA, service-worker cache | | Frontend | Vanilla JavaScript SPA, service-worker cache |
| Mobile | Capacitor 6 wrapper (Android + iOS) | | Mobile | Capacitor 6 wrapper (Android + iOS) |
| Container | Docker Compose (app + db) | | Container | Docker Compose (app + db + Redis) |
| Observability | Prometheus metrics at `/metrics`; structured app logs in files, Postgres, and optional Loki |
| Reverse proxy | External (Caddy, Nginx, Traefik — any) | | Reverse proxy | External (Caddy, Nginx, Traefik — any) |
## Repository layout ## Repository layout
@ -47,9 +48,9 @@ src/
logger.js # audit/api/access + Loki shipper logger.js # audit/api/access + Loki shipper
errors.js # generic 500 responder errors.js # generic 500 responder
models.js, prompts.js, ai.js # AI provider + model + prompt management models.js, prompts.js, ai.js # AI provider + model + prompt management
embeddings.js # Vertex / LiteLLM / OpenAI embeddings embeddings.js # LiteLLM embeddings
transcribe*.js, tts*.js # STT / TTS provider clients transcribe.js, tts.js # LiteLLM STT / TTS routes
routes/ # 27 Express routers (auth, hpi, soap, …) routes/ # Express routers (auth, hpi, soap, patient education, …)
public/ # SPA public/ # SPA
index.html # shell, loads components on demand index.html # shell, loads components on demand
@ -57,16 +58,19 @@ public/ # SPA
js/ # 24 vanilla JS modules js/ # 24 vanilla JS modules
components/ # per-tab HTML fragments components/ # per-tab HTML fragments
css/styles.css css/styles.css
models/ # bundled Whisper WASM + model files
mobile/ # Capacitor wrapper mobile/ # Capacitor wrapper
capacitor.config.json # appId com.pedshub.scribe capacitor.config.json # appId com.pedshub.scribe
src/ # launcher (server-URL picker) src/ # launcher (server-URL picker)
android/ # generated AS project + native Java android/ # generated AS project + native Java
.forgejo/workflows/
android-apk.yml # signed APK on tag push; optional Play upload
docker-build.yml # Forgejo registry Docker image build
.github/workflows/ .github/workflows/
auto-version.yml # conventional-commits → semver bump → tag auto-version.yml # conventional-commits → semver bump → tag
android-release.yml # signed APK on tag push android-release.yml # legacy GitHub tag APK release path
docker-publish.yml # multi-arch image on tag push docker-publish.yml # multi-arch image on tag push
version-bump.yml # manual dispatch override version-bump.yml # manual dispatch override
build-apk.yml # legacy TWA APK build-apk.yml # legacy TWA APK
@ -82,7 +86,7 @@ request
→ express.json (10 MB cap) → express.json (10 MB cap)
→ rate limiters (general 200 req/min, per-endpoint tighter on auth) → rate limiters (general 200 req/min, per-endpoint tighter on auth)
→ static (public/ with no-cache on HTML, 1h on JS/CSS; ?v=BUILD_ID busts cache per deploy) → static (public/ with no-cache on HTML, 1h on JS/CSS; ?v=BUILD_ID busts cache per deploy)
→ route (27 routers under /api/*) → route (feature routers under /api/*)
→ authMiddleware (on protected routes: JWT, DB session check, 24h idle, last_activity update) → authMiddleware (on protected routes: JWT, DB session check, 24h idle, last_activity update)
→ handler → handler
→ response → response
@ -121,6 +125,12 @@ per-feature HTML fragments under `public/components/` fetched on demand. JS
modules talk via `window` globals and `CustomEvent` on `document` — no modules talk via `window` globals and `CustomEvent` on `document` — no
bundler, no framework. Loader order is fixed in `index.html`. bundler, no framework. Loader order is fixed in `index.html`.
Post-note helpers such as billing suggestions, don't-miss review, and patient
education handouts are reusable browser-side actions backed by authenticated
JSON APIs. The patient education helper generates a parent-facing plain-text
draft from the edited note and keeps the clinician in the review loop before
copying or sharing.
`authFetch.js` installs a global `fetch` interceptor that treats any 401 on an `authFetch.js` installs a global `fetch` interceptor that treats any 401 on an
authenticated request as a signal to clear local session state and redirect to authenticated request as a signal to clear local session state and redirect to
login. A `BroadcastChannel('pedscribe-auth')` pushes that signal to sibling login. A `BroadcastChannel('pedscribe-auth')` pushes that signal to sibling
@ -132,8 +142,9 @@ tabs so logging out in one tab drops UI in every open tab.
|---|---|---|---| |---|---|---|---|
| `pediatric-ai-scribe` | `ped-ai-local:latest` (built from repo) | 3000 | 127.0.0.1:3552 | | `pediatric-ai-scribe` | `ped-ai-local:latest` (built from repo) | 3000 | 127.0.0.1:3552 |
| `pedscribe-db` | `pgvector/pgvector:pg16` | 5432 | not exposed | | `pedscribe-db` | `pgvector/pgvector:pg16` | 5432 | not exposed |
| `ped-ai-redis` | Redis | 6379 | not exposed |
Named volumes: `pgdata` (database), `scribe-logs` (filesystem audit logs). Named volumes: `pgdata` (database), `scribe-logs` (filesystem audit logs), and Redis data if persistence is enabled by compose.
Application health-check polls `GET /api/health`. Application health-check polls `GET /api/health`.
A reverse proxy terminates TLS and forwards to `127.0.0.1:3552`. The app is A reverse proxy terminates TLS and forwards to `127.0.0.1:3552`. The app is
@ -149,3 +160,11 @@ never bound to a public interface directly.
Precached on install: `index.html`, core JS, main stylesheet, login component. Precached on install: `index.html`, core JS, main stylesheet, login component.
Cleared on logout (`caches.keys() → caches.delete()`). Cleared on logout (`caches.keys() → caches.delete()`).
## Clinical Assistant And MCP
The clinical assistant can call an external MCP-backed retrieval service. Ped-AI remains responsible for the user workflow, provider selection, prompts, and display. MCP remains responsible for Nextcloud access, indexing, retrieval, and vector search. Clinical answer response caching is intentionally disabled; Redis is used for operational metadata and prompt suggestions, not answer reuse.
## Speech
Browser Whisper and browser-local Whisper model downloads are removed from runtime. Speech-to-text routes through LiteLLM; upstream provider choice belongs in LiteLLM config. Browser-native Web Speech remains available only when explicitly enabled by user settings and browser support.

View file

@ -123,9 +123,23 @@ necessary UX tradeoff over perfect indistinguishability.
## Turnstile (Cloudflare bot protection) ## Turnstile (Cloudflare bot protection)
Applied to `/api/auth/login`, `/register`, `/forgot-password` when Applied to `/api/auth/register` and `/api/auth/forgot-password` when
`TURNSTILE_SECRET_KEY` is set. No-op when unset (dev mode). `TURNSTILE_SECRET_KEY` is set. No-op when unset (dev mode).
`/api/auth/login` is deliberately **not** gated: the widget could not
reliably complete a challenge inside the Capacitor WebView, which locked
mobile users out of the app. Login is covered instead by its per-IP rate
limit (10 / 15 min), the constant-time credential check, and TOTP 2FA.
The two remaining widgets are rendered explicitly (`api.js?render=explicit`)
the first time their form becomes visible — Turnstile does not reliably
complete a challenge inside a `display:none` container, and both forms start
hidden. Tokens are captured from the render callback, not read back out of
the injected `[name="cf-turnstile-response"]` input.
Note that the site key is currently **hardcoded** in `public/index.html`.
`TURNSTILE_SITE_KEY` exists in OpenBao but is not read by any code.
## Encryption at rest ## Encryption at rest
`src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from `src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from
@ -146,7 +160,7 @@ Helmet defaults plus:
- `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` - `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`
- Content-Security-Policy: - Content-Security-Policy:
- `script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' cdn.jsdelivr.net cdnjs.cloudflare.com challenges.cloudflare.com` - `script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' cdn.jsdelivr.net cdnjs.cloudflare.com challenges.cloudflare.com`
(`unsafe-eval` is required by @xenova/transformers for in-browser Whisper) (do not add `unsafe-eval` unless a reviewed dependency requires it)
- `script-src-attr 'none'` (blocks inline event handlers) - `script-src-attr 'none'` (blocks inline event handlers)
- `frame-src 'self' challenges.cloudflare.com` - `frame-src 'self' challenges.cloudflare.com`
- `object-src 'none'` - `object-src 'none'`

View file

@ -1,174 +0,0 @@
# Browser Whisper Self-Hosted Setup
## Overview
As of v3, Browser Whisper is **fully self-hosted** with **zero CDN dependencies**. All models and libraries are bundled with the application and served from your own server.
## What Changed
**Before (v2 and earlier):**
- Loaded transformers.js from `cdn.jsdelivr.net`
- Downloaded models from `cdn-lfs.huggingface.co`
- Failed in corporate/clinical networks with firewall restrictions
**Now (v3+):**
- Transformers.js library (v2.6.2) bundled at `/models/transformers.min.js` (760KB)
- Whisper model bundled at `/models/Xenova/whisper-tiny.en/` (42MB)
- Everything served from your own server
- **Works in any network environment** (firewalled, air-gapped, offline)
## Files Included
```
public/models/
├── transformers.min.js (760KB) - Transformers.js v2.6.2 (worker-compatible)
└── Xenova/
└── whisper-tiny.en/ (42MB total)
├── config.json
├── tokenizer.json
├── preprocessor_config.json
├── generation_config.json
└── onnx/
├── encoder_model_quantized.onnx
└── decoder_model_merged_quantized.onnx
```
## How It Works
1. **Worker loads transformers.js locally:**
```javascript
importScripts('/models/transformers.min.js');
```
2. **Transformers.js configured for local models:**
```javascript
T.env.localModelPath = '/models/';
T.env.allowRemoteModels = false;
```
3. **Models load from your server:**
- Browser requests: `GET /models/Xenova/whisper-tiny.en/config.json`
- Served by Express static middleware
- No external network calls
## Docker Build
Models are downloaded **during Docker build** (not runtime):
```dockerfile
RUN curl -sL -o onnx/encoder_model_quantized.onnx \
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/encoder_model_quantized.onnx
```
This means:
- Docker image is ~200MB larger (one-time cost)
- Runtime has zero dependencies
- Works in air-gapped environments (after image is pulled)
## Development Setup
If you're running locally (not Docker), download models:
```bash
cd public/models
mkdir -p Xenova/whisper-tiny.en/onnx
# Download transformers.js
curl -L -o transformers.min.js \
https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js
# Download model files
cd Xenova/whisper-tiny.en
curl -L -o config.json \
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/config.json
curl -L -o tokenizer.json \
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/tokenizer.json
curl -L -o preprocessor_config.json \
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/preprocessor_config.json
curl -L -o generation_config.json \
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/generation_config.json
curl -L -o onnx/encoder_model_quantized.onnx \
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/encoder_model_quantized.onnx
curl -L -o onnx/decoder_model_merged_quantized.onnx \
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/decoder_model_merged_quantized.onnx
```
Or use the helper script:
```bash
./scripts/download-whisper-models.sh
```
## Adding More Models
To add base or small models:
1. **Create directory:**
```bash
mkdir -p public/models/Xenova/whisper-base.en/onnx
```
2. **Download from HuggingFace:**
- https://huggingface.co/Xenova/whisper-base.en
- https://huggingface.co/Xenova/whisper-small.en
3. **Update UI in `settings.html`:**
```html
<option value="Xenova/whisper-base.en">Base (~74MB, better quality)</option>
```
4. **Update Dockerfile** to download during build
## Benefits
**Works everywhere** - No firewall/CDN issues
**Privacy-first** - Audio never leaves browser
**Offline capable** - After initial page load
**No API costs** - Zero transcription expenses
**Predictable** - Same model, same results
**Fast** - Local processing, no network latency
## Limitations
- Docker image is larger (~200MB vs ~150MB)
- Only tiny model included by default (base/small optional)
- Slower than cloud APIs for long recordings
- Requires modern browser with WebAssembly support
## Testing
```bash
# 1. Start server
docker-compose up -d
# 2. Open browser DevTools → Network tab
# 3. Go to Settings → Browser Transcription
# 4. Click "Pre-download model"
# 5. Watch for requests to /models/* (should all be 200 OK from your server)
# 6. NO requests to cdn.jsdelivr.net or huggingface.co
```
## Troubleshooting
**Issue: "Failed to load transformers library"**
- Check: `GET /models/transformers.min.js` returns 200 OK
- Verify file exists: `ls public/models/transformers.min.js`
**Issue: "Model load failed"**
- Check: `GET /models/Xenova/whisper-tiny.en/config.json` returns 200 OK
- Verify files exist: `ls public/models/Xenova/whisper-tiny.en/`
**Issue: Still seeing CDN requests**
- Clear browser cache (Ctrl+Shift+R)
- Check you're running v18+ (`/api/health` should show version)
## Migration from v17
If upgrading from v17:
1. Pull new Docker image: `docker-compose pull`
2. Restart: `docker-compose up -d`
3. Clear browser cache
4. Test: Settings → Browser Transcription → Pre-download
No configuration changes needed - it just works!

View file

@ -1,240 +0,0 @@
# Browser Whisper Troubleshooting
## 🎙️ What is Browser Whisper?
Browser Whisper is an **optional** client-side transcription feature that runs entirely in your browser using WebAssembly. It provides:
- ✅ Zero network transmission (HIPAA-safe)
- ✅ No API costs
- ✅ Works offline
- ✅ Privacy-first (audio never leaves device)
**However**, it requires downloading AI models from CDN servers.
---
## ⚠️ Common Issue: CDN Blocked
### Error Message:
```
NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope':
The script at 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2' failed to load.
```
### What This Means:
Your network/firewall is blocking access to:
- `cdn.jsdelivr.net` (JavaScript library CDN)
- `cdn-lfs.huggingface.co` (AI model files)
### Why It Happens:
1. **Corporate firewall** - Many organizations block CDN domains
2. **Browser extensions** - Ad blockers, privacy tools may block CDN
3. **Network proxy** - Company proxy might filter JavaScript CDN
4. **CSP restrictions** - Very strict Content Security Policy
---
## ✅ Solutions
### Option 1: Use Server Transcription (Recommended)
**Browser Whisper is optional!** The app works perfectly fine with server-side transcription.
**Server transcription providers:**
- Google Gemini (via Vertex AI) - HIPAA-eligible
- AWS Transcribe - HIPAA-eligible
- OpenAI Whisper - Fast, accurate
- LiteLLM - Routes to any provider
**To use server transcription:**
1. Go to Settings → Browser Transcription
2. **Leave it disabled** (or if stuck, disable it)
3. Record audio normally - will use server
**Advantages:**
- More accurate (larger models)
- No download needed
- Works immediately
- Professional grade
### Option 2: Whitelist CDN Domains
If you control your network/firewall, whitelist these domains:
```
cdn.jsdelivr.net
cdn-lfs.huggingface.co
cdn-lfs-us-1.huggingface.co
cdn-lfs-us-2.huggingface.co
huggingface.co
```
**For corporate IT:**
- These are legitimate AI/JavaScript CDNs
- Used by major companies worldwide
- No security risk (public CDN content)
- Required only for browser-based AI features
### Option 3: Disable Browser Extensions
Try disabling:
- Ad blockers (uBlock Origin, AdBlock Plus)
- Privacy extensions (Privacy Badger, Ghostery)
- Script blockers (NoScript, ScriptSafe)
Then refresh and try again.
### Option 4: Try Different Browser
Some browsers have stricter security:
- ✅ **Chrome** - Best compatibility
- ✅ **Edge** - Works well
- ⚠️ **Firefox** - May block CDN
- ❌ **Safari** - Limited WebAssembly support
---
## 🧪 How to Test If It's Working
### Test 1: Check CDN Access
```bash
# From your computer, run:
curl -I https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2
# Should return: HTTP/2 200
# If 403 or timeout: CDN is blocked
```
### Test 2: Browser Console
1. Open DevTools (F12)
2. Go to Console tab
3. Settings → Browser Transcription
4. Click "Pre-download model"
5. Watch for:
```
✅ [WhisperWorker] Transformers library loaded successfully
OR
❌ NetworkError: Failed to load
```
### Test 3: Network Tab
1. Open DevTools (F12)
2. Go to Network tab
3. Click "Pre-download model"
4. Look for requests to:
- `cdn.jsdelivr.net` (should be 200 OK)
- `cdn-lfs.huggingface.co` (should be 200 OK)
5. If blocked: Status will show "failed" or "blocked"
---
## 📊 When to Use Each Option
| Scenario | Recommendation | Why |
|----------|---------------|-----|
| Corporate network | **Server transcription** | CDN likely blocked |
| Home network | **Browser Whisper** | Fast, free, private |
| Mobile device | **Server transcription** | Limited storage/memory |
| Offline use needed | **Browser Whisper** | Works without internet (after initial download) |
| High accuracy needed | **Server transcription** | Larger models available |
| Maximum privacy | **Browser Whisper** | Audio never leaves device |
| Can't access CDN | **Server transcription** | No choice - CDN blocked |
---
## 🔧 Technical Details
### What Gets Downloaded (First Time Only):
**Tiny model** (~39 MB):
- onnx-runtime.wasm (~10 MB)
- whisper-tiny.en model files (~29 MB)
- Cached in browser IndexedDB (permanent)
**Base model** (~74 MB):
- Larger model, better accuracy
**Small model** (~244 MB):
- Best quality, slower processing
### Where It's Stored:
- **Location:** Browser IndexedDB
- **Persistence:** Permanent (until you clear browser data)
- **Shared:** Across all tabs/windows for this domain
- **Size:** Selected model size (39/74/244 MB)
### Performance:
- **Tiny:** 2-3 seconds per 30-second clip
- **Base:** 3-5 seconds per 30-second clip
- **Small:** 6-10 seconds per 30-second clip
---
## ❓ FAQ
**Q: Is Browser Whisper required?**
A: No! It's completely optional. Server transcription works great.
**Q: Why doesn't it work on my corporate network?**
A: Most corporate firewalls block CDN domains for security. Use server transcription instead.
**Q: Can I download the models manually?**
A: Not easily - they're optimized for CDN delivery. Use server transcription if CDN is blocked.
**Q: Will server transcription cost money?**
A: Depends on your provider:
- Google Vertex AI: ~$0.005 per minute
- AWS Transcribe: ~$0.024 per minute
- OpenAI: $0.006 per minute
- Very affordable for typical use
**Q: Is server transcription HIPAA-safe?**
A: Yes, if using:
- Google Vertex AI (with BAA)
- AWS Transcribe (with BAA)
- Azure OpenAI (with BAA)
OpenAI Whisper direct is NOT HIPAA-eligible.
**Q: Can I use both?**
A: Yes! Enable Browser Whisper in Settings. If it fails (CDN blocked), it automatically falls back to server transcription.
**Q: How do I know which one is being used?**
A: Check the toast notification after recording:
- "Transcribed locally" = Browser Whisper
- "Transcribed via google-gemini/aws/openai" = Server
---
## 🚀 Recommended Setup
### For Maximum Privacy (Home Network):
1. Enable Browser Whisper
2. Choose "Tiny" model (fast, good enough for dictation)
3. Pre-download model
4. Use offline
### For Corporate/Clinical Use:
1. Keep Browser Whisper **disabled**
2. Configure server transcription:
```bash
# In .env:
TRANSCRIBE_PROVIDER=google
GOOGLE_VERTEX_PROJECT=your-project
```
3. Use with BAA for HIPAA compliance
### For Best Accuracy:
1. Use server transcription
2. Configure Google Gemini 2.0 Flash or AWS Transcribe Medical
3. Audio quality + large models = best results
---
## 🛠️ Still Having Issues?
1. **Check console logs:** DevTools → Console → Look for `[BrowserWhisper]` errors
2. **Check network logs:** DevTools → Network → Filter by `jsdelivr` or `huggingface`
3. **Verify server transcription works:** Just disable Browser Whisper and record
4. **Contact IT:** Ask to whitelist CDN domains (if you need Browser Whisper)
**Remember:** Browser Whisper is a nice-to-have feature. Server transcription is the primary, production-ready method that works everywhere!

View file

@ -29,39 +29,33 @@ keys):
| Variable | Purpose | | Variable | Purpose |
|---|---| |---|---|
| `AI_PROVIDER` | `openrouter` / `bedrock` / `azure` / `vertex` / `litellm`. Auto-detected by credential presence if unset. | | `AI_PROVIDER` | `openrouter` / `bedrock` / `azure` / `vertex` / `litellm`. If unset, the startup loader uses configured credentials and the last initialized provider in Bedrock → Azure → Vertex → LiteLLM order wins; otherwise OpenRouter is the default. |
| `OPENROUTER_API_KEY` | OpenRouter key (not HIPAA-eligible). | | `OPENROUTER_API_KEY` | OpenRouter key (not HIPAA-eligible). |
| `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Bedrock / Transcribe / Transcribe-Medical. | | `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Bedrock chat provider. |
| `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_VERSION` | Azure OpenAI. | | `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_VERSION` | Azure OpenAI. |
| `GOOGLE_VERTEX_PROJECT`, `GOOGLE_VERTEX_LOCATION`, `GOOGLE_APPLICATION_CREDENTIALS` | Vertex AI + Gemini (STT/TTS). | | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_VERTEX_LOCATION`, `GOOGLE_APPLICATION_CREDENTIALS` | Vertex AI chat provider. |
| `LITELLM_API_BASE`, `LITELLM_API_KEY` | OpenAI-compatible AI gateway (Bifrost, LiteLLM, or similar). | | `LITELLM_API_BASE`, `LITELLM_API_KEY` | OpenAI-compatible AI gateway (Bifrost, LiteLLM, or similar). |
### Speech-to-text ### Speech-to-text
| Variable | Purpose | | Variable | Purpose |
|---|---| |---|---|
| `TRANSCRIBE_PROVIDER` | `google`, `aws`, `local`, `openai`, `litellm`. Auto-detects if unset. | | `TRANSCRIBE_PROVIDER` | Use `litellm`; auto mode uses LiteLLM when configured. |
| `OPENAI_API_KEY` | OpenAI Whisper. |
| `GOOGLE_STT_MODEL` | Gemini model used as STT (default `gemini-2.0-flash`). |
| `AWS_TRANSCRIBE_MEDICAL` | `true` enables Transcribe Medical. |
| `AWS_TRANSCRIBE_SPECIALTY` | `PRIMARYCARE` / `CARDIOLOGY` / `NEUROLOGY` / `ONCOLOGY` / `RADIOLOGY` / `UROLOGY`. |
| `WHISPER_BINARY`, `WHISPER_MODEL_SIZE`, `WHISPER_MODEL_PATH`, `WHISPER_LANGUAGE`, `WHISPER_THREADS` | Local whisper.cpp / faster-whisper. |
| `LITELLM_STT_MODEL` | Model name for LiteLLM-routed STT. | | `LITELLM_STT_MODEL` | Model name for LiteLLM-routed STT. |
### Text-to-speech ### Text-to-speech
| Variable | Purpose | | Variable | Purpose |
|---|---| |---|---|
| `GOOGLE_TTS_VOICE` | Google Cloud TTS voice (e.g. `en-US-Journey-F`). | | `LITELLM_TTS_MODEL`, `LITELLM_TTS_VOICE` | LiteLLM-routed TTS model and default voice. |
| `ELEVENLABS_API_KEY` | ElevenLabs (not HIPAA-compliant). | | `LITELLM_TTS_VOICES` | Comma-separated LiteLLM-compatible voices exposed in voice search and user preferences. |
| `LITELLM_TTS_MODEL`, `LITELLM_TTS_VOICE` | LiteLLM-routed TTS. |
### Embeddings ### Embeddings
| Variable | Purpose | | Variable | Purpose |
|---|---| |---|---|
| `EMBEDDING_MODEL` | Embedding model name (default `text-embedding-005`, Vertex). | | `EMBEDDING_MODEL` | LiteLLM embedding model name (default `openai-text-embedding-3-large`). |
| `EMBEDDING_DIMENSIONS` | Vector dimensions (default 768). | | `EMBEDDING_DIMENSIONS` | Vector dimensions (default 3072). |
### Email (SMTP) ### Email (SMTP)
@ -178,8 +172,8 @@ OpenAI-compatible gateway — LiteLLM, Bifrost, or other proxies.
3. **Update model names** — Different gateways use different naming 3. **Update model names** — Different gateways use different naming
conventions. Bifrost requires `provider/model` format conventions. Bifrost requires `provider/model` format
(e.g., `openrouter/vendor-model-sonnet-4.6`), while LiteLLM uses aliases (e.g., `openrouter/gpt-4.1`), while LiteLLM can use deployment aliases
(e.g., `openrouter-vendor-model-sonnet-4.6`). Update model names in: (e.g., `openrouter-gpt-4.1`). Update model names in:
- Admin Panel → Models (chat models) - Admin Panel → Models (chat models)
- Admin Panel → Settings → `stt.model` (speech-to-text) - Admin Panel → Settings → `stt.model` (speech-to-text)
- Admin Panel → Settings → `tts.model` (text-to-speech) - Admin Panel → Settings → `tts.model` (text-to-speech)

View file

@ -138,20 +138,23 @@ Draft/complete encounter workspace. Auto-expires (default 7 d,
### `user_memories` ### `user_memories`
Per-user clinical-style hints injected into AI prompts. Per-user template and preference rows. Only selected categories are injected
into AI generation through `/api/memories/context`; `custom` rows are stored
for the user but not included in prompt context.
| Column | Type | Notes | | Column | Type | Notes |
|---|---|---| |---|---|---|
| id | SERIAL PK | | | id | SERIAL PK | |
| user_id | INTEGER FK users.id ON DELETE CASCADE | | | user_id | INTEGER FK users.id ON DELETE CASCADE | |
| category | TEXT NOT NULL DEFAULT 'custom' | `physical_exam`, `ros`, `encounter_format`, `custom`, `template_*`, `correction_*` | | category | TEXT NOT NULL DEFAULT 'custom' | Valid categories: `physical_exam`, `ros`, `encounter_format`, `family_history`, `assessment_plan`, `custom`, `template_soap`, `template_hpi`, `template_wellvisit`, `template_sickvisit`, `template_ed`. Legacy `correction_*` rows may exist but are filtered out. |
| name | TEXT NOT NULL | | | name | TEXT NOT NULL | Encrypted with `enc1:` for new rows |
| content | TEXT NOT NULL | | | content | TEXT NOT NULL | Encrypted with `enc1:` for new rows |
| created_at, updated_at | TIMESTAMPTZ DEFAULT NOW() | | | created_at, updated_at | TIMESTAMPTZ DEFAULT NOW() | |
### `audio_backups` ### `audio_backups`
Retry store for failed-transcription audio. Optional 24-hour encrypted recovery store for recordings when transcription
fails, so users can retry without re-recording.
| Column | Type | Notes | | Column | Type | Notes |
|---|---|---| |---|---|---|

View file

@ -10,8 +10,9 @@
| Image | Role | | Image | Role |
|---|---| |---|---|
| `danielonyejesi/pediatric-ai-scribe-v3:latest` | App container. Published by CI on every tag push (multi-arch: `linux/amd64` + `linux/arm64`). Pull directly or build from source. | | `danielonyejesi/pediatric-ai-scribe-v3:latest` | App container. Published by CI on every tag push where configured. Pull directly or build from source. |
| `pgvector/pgvector:pg16` | Database. | | `pgvector/pgvector:pg16` | Database. |
| `redis:7-alpine` | Operational Redis cache/state. |
## Build from source ## Build from source
@ -23,8 +24,7 @@ cp .env.example .env
docker compose up -d --build docker compose up -d --build
``` ```
Two containers come up: `pediatric-ai-scribe` on `127.0.0.1:3552`, `pedscribe-db` The default compose starts `pediatric-ai-scribe` on `127.0.0.1:3552`, `pedscribe-db` internally, and `ped-ai-redis` internally.
internal only.
## Minimum `.env` ## Minimum `.env`
@ -80,7 +80,7 @@ App sets `trust proxy: 1` so rate limiting uses the original client IP.
| Volume | Contents | Backup priority | | Volume | Contents | Backup priority |
|---|---|---| |---|---|---|
| `pgdata` | All user data, encounters, memories, audit logs, settings, embeddings | Critical | | `pgdata` | All user data, encounters, memories, audit logs, settings, embeddings | Critical |
| `scribe-logs` | Filesystem audit log files (JSONL by day) | Low — Postgres also has these in `audit_log` table | | `scribe-logs` | Filesystem audit log files (JSONL by day) | High for compliance evidence; Postgres also has audit/API/access tables |
### Postgres backup / restore ### Postgres backup / restore
@ -120,6 +120,7 @@ REINDEXes if the ICU library version changed between image builds.
| `GET /api/health` | `{ok:true}` — public, used by Docker health check | | `GET /api/health` | `{ok:true}` — public, used by Docker health check |
| `GET /api/health/detailed` | Provider status — admin-auth required | | `GET /api/health/detailed` | Provider status — admin-auth required |
| `GET /api/build` | Build ID (short git SHA) — useful for debugging cache invalidation | | `GET /api/build` | Build ID (short git SHA) — useful for debugging cache invalidation |
| `GET /metrics` | Prometheus metrics in text exposition format |
Docker health check in `Dockerfile`: every 30 s, wget-spiders `/api/health`. Docker health check in `Dockerfile`: every 30 s, wget-spiders `/api/health`.
Container marked unhealthy after 5 failures. Container marked unhealthy after 5 failures.
@ -127,7 +128,7 @@ Container marked unhealthy after 5 failures.
## Resource footprint ## Resource footprint
- RAM: 256 MB minimum, 512 MB recommended for one instance with a handful of concurrent users. - RAM: 256 MB minimum, 512 MB recommended for one instance with a handful of concurrent users.
- Disk: ~220 MB image (self-hosted Whisper WASM included). Postgres size scales with audit log retention. - Disk: Postgres size scales with audit log retention, saved encounters, documents, and Learning Hub content.
- CPU: idle load negligible; AI calls are network-bound on the LLM provider side. - CPU: idle load negligible; AI calls are network-bound on the LLM provider side.
## Production checklist ## Production checklist
@ -141,14 +142,15 @@ Container marked unhealthy after 5 failures.
- Turnstile keys set for public-facing deployments - Turnstile keys set for public-facing deployments
- Reverse proxy serves valid TLS certs - Reverse proxy serves valid TLS certs
- Postgres dump scheduled off-host - Postgres dump scheduled off-host
- Log retention and backup policy covers `audit_log`, `api_log`, `access_log`, and filesystem `scribe-logs`
## CI / CD ## CI / CD
Four workflows fire on tag push: On push (and tag push), these workflows run (depending on runner/site):
| Workflow | Output | Runtime | | Workflow | Output | Runtime |
|---|---|---| |---|---|---|
| `android-release.yml` | Signed APK attached to the GitHub release | ~8 min | | `.forgejo/workflows/android-apk.yml` | Signed APK attached to the Forgejo release, plus optional Google Play internal track upload | ~8 min |
| `docker-publish.yml` | Multi-arch image (amd64 + arm64 via native runners) on Docker Hub | ~4 min | | `docker-publish.yml` | Multi-arch image (amd64 + arm64 via native runners) on Docker Hub | ~4 min |
| `build-apk.yml` | Legacy TWA APK (optional second artifact) | ~2 min | | `build-apk.yml` | Legacy TWA APK (optional second artifact) | ~2 min |
@ -162,6 +164,7 @@ Triggered by `auto-version.yml` (reads commit messages, bumps + tags via
|---|---|---| |---|---|---|
| App | 3000 | 127.0.0.1:3552 | | App | 3000 | 127.0.0.1:3552 |
| Postgres | 5432 | not exposed | | Postgres | 5432 | not exposed |
| Redis | 6379 | not exposed |
Change the app's external port by editing the `ports:` mapping in Change the app's external port by editing the `ports:` mapping in
`docker-compose.yml`. `docker-compose.yml`.
@ -174,6 +177,8 @@ Change the app's external port by editing the `ports:` mapping in
via `src/utils/auditQueue.js`, drained on SIGTERM. via `src/utils/auditQueue.js`, drained on SIGTERM.
4. Loki (if `LOKI_URL` set) — pushed fire-and-forget per event. 4. Loki (if `LOKI_URL` set) — pushed fire-and-forget per event.
A central Prometheus/Loki/Grafana stack can also scrape `GET /metrics` and collect Docker logs with Promtail. Keep direct Loki push enabled only for structured application events that are useful for compliance and operations.
## Auto-cleanup ## Auto-cleanup
| Target | Policy | Frequency | | Target | Policy | Frequency |

View file

@ -1,976 +0,0 @@
# Pediatric AI Scribe — Developer Guide
**Version:** 6.0 | **Stack:** Node.js / Express / PostgreSQL / Vanilla JS
---
## Table of Contents
1. [Project Overview](#1-project-overview)
2. [Architecture](#2-architecture)
3. [Directory Structure](#3-directory-structure)
4. [Environment Variables](#4-environment-variables)
5. [Database Schema](#5-database-schema)
6. [Authentication System](#6-authentication-system)
7. [Backend API Reference](#7-backend-api-reference)
8. [Frontend Architecture](#8-frontend-architecture)
9. [AI Integration](#9-ai-integration)
10. [Learning Hub & CMS](#10-learning-hub--cms)
11. [Deployment](#11-deployment)
12. [Known Issues & Security Notes](#12-known-issues--security-notes)
13. [Adding New Features](#13-adding-new-features)
14. [Resetting Admin Password via Console](#14-resetting-admin-password-via-console)
---
## 1. Project Overview
Pediatric AI Scribe is a clinical documentation platform for pediatric healthcare providers. It uses AI (via OpenRouter, AWS Bedrock, or Azure OpenAI) to generate:
- HPI notes from live encounter recordings
- SOAP notes from dictation
- Hospital course summaries
- Chart reviews
- Well-visit notes (including SSHADESS, ROS/PE, milestones)
- Sick visit notes
- Learning Hub content (articles, quizzes, clinical pearls, presentations)
**Key design principle:** Single-page application. All tabs are lazy-loaded HTML components (`/public/components/*.html`). JavaScript modules initialize only when their tab is first activated via the `tabChanged` custom event.
---
## 2. Architecture
```
Browser (Vanilla JS + Tiptap)
|
| HTTP (JWT Bearer token in Authorization header)
|
Express.js (Node.js) — server.js
|
|— Helmet (CSP, security headers)
|— CORS (restricted to APP_URL in production)
|— express-rate-limit (login: 10/15min, register: 5/hr, resend-verify: 3/15min, general: 60/min)
|— cookie-parser
|— Routes (/src/routes/)
|
PostgreSQL (pg driver, no ORM)
|
|— users, app_settings, audit_log, saved_encounters
|— user_memories, learning_*, access_log, api_log
```
### How Requests Flow
1. **Browser** sends HTTP request with `Authorization: Bearer <jwt>` header
2. **Express middleware chain:** Helmet (security headers) → CORS → rate limiter → body parser → logging middleware → route handler
3. **Auth middleware** (`src/middleware/auth.js`) decodes JWT, queries `users` table, attaches `req.user` with `{ id, email, name, role }`
4. **Route handler** processes the request — for AI routes, calls `callAI()` which routes to the configured provider
5. **Database** is accessed via the `pg` driver directly (no ORM). All queries use parameterized placeholders (`$1`, `$2`) to prevent SQL injection
6. **Response** is JSON for API calls, or static files served from `/public`
### AI Providers
Configured via environment variables. The provider is selected at startup in `src/utils/ai.js` using this priority:
1. **AWS Bedrock** — if `AWS_BEDROCK_REGION` is set. HIPAA eligible with BAA. Uses `@aws-sdk/client-bedrock-runtime`. Anthropic models use the native Messages API (`InvokeModel`); all others use the Converse API.
2. **Azure OpenAI** — if `AZURE_OPENAI_ENDPOINT` is set. HIPAA eligible. Uses the OpenAI SDK pointed at your Azure endpoint.
3. **OpenRouter** — default fallback if `OPENROUTER_API_KEY` is set. Routes to 20+ models from various providers. Not HIPAA compliant.
The provider cannot be changed at runtime — it's determined once at startup. To switch providers, update `.env` and restart the container.
### Database Layer
The app uses **raw SQL via the `pg` driver** — no ORM (Sequelize, Prisma, etc.). This is intentional:
- **Simplicity:** Every query is visible and explicit. No magic, no migrations framework, no model definitions to sync.
- **Performance:** No ORM overhead or N+1 query problems.
- **Schema management:** `src/db/database.js` runs `CREATE TABLE IF NOT EXISTS` on startup, plus `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for migrations. This means the schema is always up-to-date when the app starts.
- **Future ORM migration:** If needed, the queries are standard PostgreSQL and can be wrapped by any ORM. The main work would be defining models and replacing direct `db.get()`/`db.run()` calls.
The `database.js` file exports a helper object (`db`) with convenience methods:
- `db.get(sql, params)` — returns first row or `null`
- `db.all(sql, params)` — returns all rows as array
- `db.run(sql, params)` — executes INSERT/UPDATE/DELETE, returns `{ rowCount }`
- `db.getSetting(key)` / `db.setSetting(key, value)` — shorthand for `app_settings` table
---
## 3. Directory Structure
```
/
├── server.js # Express app entry point (route registration, Helmet CSP,
│ # rate limiters, static file serving, error handlers)
├── package.json # Dependencies (~25 production deps, no devDeps)
├── Dockerfile # Multi-stage Node.js 20 Alpine build
├── docker-compose.yml # Production compose (uses Docker Hub image)
├── docker-compose.local.yml # Local development (builds from source, port 3552)
├── admin-cli.js # CLI tool for admin tasks (create user, reset password)
├── DEVELOPER_GUIDE.md # This file
├── src/
│ ├── db/
│ │ └── database.js # DB connection pool (pg.Pool), schema init
│ │ # (CREATE TABLE IF NOT EXISTS for all tables),
│ │ # column migrations (ALTER TABLE ADD COLUMN IF NOT EXISTS),
│ │ # helper methods: db.get(), db.all(), db.run(),
│ │ # db.getSetting(), db.setSetting()
│ ├── middleware/
│ │ ├── auth.js # authMiddleware (JWT decode → req.user),
│ │ # adminMiddleware (role === 'admin'),
│ │ # moderatorMiddleware (role === 'admin' || 'moderator')
│ │ └── logging.js # Logs every request to api_log table (method, path, user, IP, duration)
│ ├── routes/
│ │ ├── auth.js # Login, register, 2FA, password reset, /me
│ │ ├── admin.js # User management (admin only)
│ │ ├── adminConfig.js # Site settings, feature flags, AI prompts, models
│ │ ├── encounters.js # Save/load/delete draft encounters
│ │ ├── memories.js # User templates (physical exam, ROS, etc.)
│ │ ├── hpi.js # Generate HPI from encounter/dictation transcript
│ │ ├── soap.js # Generate SOAP note
│ │ ├── hospitalCourse.js # Generate hospital course summary
│ │ ├── chartReview.js # Generate outpatient chart review
│ │ ├── milestones.js # Generate developmental milestone narrative
│ │ ├── wellVisit.js # Well-visit note generation (ROS/PE/ICD-10)
│ │ ├── sickVisit.js # Sick visit note generation
│ │ ├── refine.js # Refine/shorten any generated document
│ │ ├── transcribe.js # Whisper audio transcription
│ │ ├── tts.js # Text-to-speech (if configured)
│ │ ├── nextcloud.js # Nextcloud WebDAV connect/export/disconnect
│ │ ├── learningHub.js # User-facing: feed, content, quiz submission
│ │ ├── learningAdmin.js # CMS: categories, content, questions CRUD
│ │ ├── learningAI.js # AI generation for Learning Hub content
│ │ └── logs.js # Usage/audit/API/access logs + client error
│ └── utils/
│ ├── ai.js # callAI(messages, options) — routes to OpenRouter/Bedrock/Azure.
│ │ # Handles Anthropic InvokeModel (Messages API) vs Converse API,
│ │ # thinking block extraction, fallback model retry, duration tracking.
│ ├── models.js # OPENROUTER_MODELS[], BEDROCK_MODELS[], AZURE_MODELS[]
│ │ # Each model: { id, name, cost, tag, category, bedrockId, maxOut, regions }
│ │ # getBedrockModelId() maps app IDs to Bedrock/inference profile IDs.
│ │ # getAvailableModels() filters by region. getAvailableModelsWithOverrides()
│ │ # applies admin-disabled/custom models from DB.
│ ├── prompts.js # Default prompt templates for every AI route. Loaded on startup,
│ │ # then overridden by DB values (app_settings: 'prompt.*' keys).
│ │ # PROMPTS.get('key') returns the DB override or default.
│ ├── config.js # App configuration helpers
│ └── logger.js # Winston logger (file + console, JSON format)
├── public/
│ ├── index.html # Single HTML shell, loads all components
│ ├── 404.html # Custom 404 page
│ ├── css/
│ │ └── styles.css # All CSS (single file, ~750 lines)
│ ├── js/
│ │ ├── app.js # Core: tab switching via data-tab buttons, loadComponent()
│ │ │ # fetches HTML from /components/, global helpers (showToast,
│ │ │ # showLoading, getAuthHeaders, getSelectedModel, etc.)
│ │ ├── auth.js # Login/register/forgot-password forms, JWT storage in
│ │ │ # localStorage ('ped_scribe_token'), enterApp()/clearSession(),
│ │ │ # resend verification link handler, 2FA code input
│ │ ├── admin.js # Admin panel: user management, site settings, SMTP config,
│ │ │ # model enable/disable, prompt editor, announcement banner
│ │ ├── liveEncounter.js # MediaRecorder → Whisper transcription → AI HPI generation.
│ │ │ # Handles start/stop recording, timer, save/load encounters
│ │ ├── voiceDictation.js # Web Speech API (real-time) or Whisper (recorded) dictation
│ │ ├── hospitalCourse.js # Paste/dictate hospital course → AI summary
│ │ ├── chartReview.js # Paste/dictate chart data → AI outpatient review
│ │ ├── soap.js # Paste/dictate → AI SOAP note
│ │ ├── milestones.js # Age-based milestone checklist → AI narrative
│ │ ├── wellVisit.js # Well Visit guide: vaccine schedule display, age calculator
│ │ ├── shadess.js # SSHADESS psychosocial form + ROS/PE checkboxes → AI note
│ │ ├── sickVisit.js # Chief complaint + HPI → AI sick visit SOAP
│ │ ├── nextcloud.js # Nextcloud WebDAV connect/disconnect/export settings UI
│ │ ├── encounters.js # Save/load/delete encounter drafts (shared across all tabs)
│ │ ├── memories.js # User template CRUD (physical exam defaults, ROS, etc.)
│ │ ├── learningHub.js # Learning Hub (user feed, content viewer, quiz engine) +
│ │ │ # CMS (category CRUD, content editor with Tiptap, question
│ │ │ # builder, AI generation panel, Nextcloud file picker,
│ │ │ # slide preview modal). Single file, ~1400 lines.
│ │ ├── milestonesData.js # Static milestone data by age group (2mo → 6yr)
│ │ └── pediatricScheduleData.js # CDC vaccine schedule data + catch-up schedule
│ ├── components/ # Lazy-loaded tab HTML (injected by loadComponent)
│ │ ├── encounter.html ├── dictation.html ├── hospital.html
│ │ ├── chart.html ├── soap.html ├── wellvisit.html
│ │ ├── sickvisit.html ├── vaxschedule.html ├── catchup.html
│ │ ├── learning.html ├── cms.html ├── admin.html
│ │ └── settings.html
│ └── vendor/
│ └── tiptap.bundle.js # Tiptap 2 + extensions (esbuild bundle, self-hosted)
```
---
## 4. Environment Variables
Set in `.env` file (copy `.env.example` to get started):
```bash
# ── Required ──────────────────────────────────────────────────
DATABASE_URL=postgresql://user:pass@host:5432/dbname
JWT_SECRET=change-this-to-a-random-64-char-string
# ── AI Provider (choose one or let it default to OpenRouter) ──
OPENROUTER_API_KEY=sk-or-... # Default provider
# OR
AZURE_OPENAI_ENDPOINT=https://... # Azure (HIPAA eligible)
AZURE_OPENAI_API_KEY=...
AZURE_DEPLOYMENT_NAME=gpt-4o-mini
AZURE_OPENAI_API_VERSION=2024-08-01-preview
# OR
AWS_BEDROCK_REGION=us-east-1 # AWS Bedrock (HIPAA eligible)
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
# ── Optional ──────────────────────────────────────────────────
OPENAI_API_KEY=sk-... # For Whisper transcription only
APP_URL=https://yourdomain.com # Enables secure CORS + Secure cookies
NODE_ENV=production # Enables production optimizations
PORT=3000 # Default: 3000
# ── Email (for password reset, registration verification) ──────
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=noreply@example.com
SMTP_PASS=...
SMTP_FROM=Pediatric AI Scribe <noreply@example.com>
```
**Note:** If no SMTP is configured, registration auto-verifies and password reset won't work. Configure SMTP or use the console reset method (see Section 14).
---
## 5. Database Schema
All tables are created automatically on first run by `src/db/database.js`. The file runs `CREATE TABLE IF NOT EXISTS` for every table, followed by `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` migrations for upgrades.
### Core Tables
#### `users`
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| email | TEXT UNIQUE | Lowercase |
| password | TEXT | bcrypt hash (cost 12) |
| name | TEXT | Display name |
| role | TEXT | `'user'` \| `'moderator'` \| `'admin'` |
| totp_enabled | BOOLEAN | 2FA status |
| totp_secret | TEXT | TOTP secret (base32) |
| disabled | BOOLEAN | Soft disable |
| email_verified | BOOLEAN | |
| verify_token / verify_expires | TEXT / BIGINT | Email verification |
| reset_token / reset_expires | TEXT / BIGINT | Password reset |
| nextcloud_url / nextcloud_user / nextcloud_token / nextcloud_folder | TEXT | Nextcloud integration |
| webdav_learning_path | TEXT | Default WebDAV path for Learning Hub file picker |
| created_at | TIMESTAMPTZ | |
#### `app_settings`
Key-value store for all site configuration. Read via `db.getSetting(key)`, written via admin panel or direct DB.
Important keys:
- `registration_enabled``'true'` / `'false'`
- `announcement.enabled` / `announcement.text` / `announcement.type`
- `smtp.*` — SMTP config (overrides env vars)
- `ai.prompt.*` — AI prompt overrides
- `model.*` — enabled/disabled models
#### `saved_encounters`
Draft encounters (7-day auto-expiry). Columns: `label`, `enc_type`, `transcript`, `generated_note`, `partial_data` (JSON), `status`, `expires_at`.
#### `user_memories`
User templates fed into AI generation. `category` is one of: `physical_exam`, `ros`, `encounter_format`, `family_history`, `assessment_plan`, `custom`.
### Learning Hub Tables
#### `learning_categories`
Simple category list with `name`, `slug`, `sort_order`.
#### `learning_content`
Articles, quizzes, pearls, presentations. Key columns: `title`, `slug`, `body` (HTML for articles/pearls/quizzes; Marp markdown for presentations), `content_type` (`article` | `quiz` | `pearl` | `presentation`), `published`, `author_id`.
#### `learning_questions`
Quiz questions linked to `learning_content`. `question_type`: `mcq` | `true_false` | `multi`. `explanation` = general explanation shown after answering.
#### `learning_options`
Answer options for quiz questions. `is_correct: boolean`, `explanation` = shown when this wrong option is chosen.
#### `learning_progress`
Quiz attempt scores per user per content item.
---
## 6. Authentication System
**Current implementation: JWT in localStorage**
### Flow
1. `POST /api/auth/login` → returns `{ success, token, user }`
2. Frontend stores token in `localStorage` as `ped_scribe_token` and in `window.AUTH_TOKEN`
3. All API calls include `Authorization: Bearer <token>` header via `getAuthHeaders()`
4. `src/middleware/auth.js` validates the Bearer token, attaches `req.user`
5. Logout: `clearSession()` removes token from localStorage (client-side only)
### Token
- Signed with `JWT_SECRET` env var
- 7-day expiry
- Payload: `{ userId: number }`
### Roles
- `user` — standard access (clinical tools only)
- `moderator` — can create/edit Learning Hub content
- `admin` — full access including user management and site settings
### Middleware
- `authMiddleware` — validates JWT, populates `req.user`
- `adminMiddleware` — run after auth, requires `role === 'admin'`
- `moderatorMiddleware` — run after auth, requires `role === 'admin' OR 'moderator'`
### 2FA
Uses TOTP (speakeasy). If enabled, login returns `{ requires2FA: true }` and the client must POST the TOTP code to complete login.
### Session Check on Page Load (auth.js)
```javascript
var savedToken = localStorage.getItem('ped_scribe_token');
if (savedToken) {
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + savedToken } })
.then(/* if ok → enterApp(), else → clearSession() */);
}
```
The `has-session` CSS class on `<html>` hides the auth screen immediately when a localStorage token exists, preventing a white flash.
---
## 7. Backend API Reference
All routes are prefixed `/api`. Routes requiring auth are marked (A). Admin-only: (ADM). Moderator+: (MOD).
### Auth — `/api/auth/`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/login` | — | Email + password login. Returns `{ token, user }` |
| POST | `/register` | — | Create account (checks `registration_enabled` setting) |
| GET | `/me` | A | Returns current user object |
| POST | `/logout` | — | Clears server-side state (currently no-op, kept for future) |
| POST | `/setup-2fa` | A | Generates TOTP secret + QR code |
| POST | `/verify-2fa` | A | Confirms TOTP code, enables 2FA |
| POST | `/disable-2fa` | A | Disables 2FA (requires password) |
| POST | `/forgot-password` | — | Sends reset email |
| POST | `/reset-password` | — | Sets new password via reset token |
| GET | `/registration-status` | — | Returns `{ registrationEnabled: bool }` |
| GET | `/verify-email` | — | Verifies email via token in query string |
### Clinical — AI Generation
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/generate-hpi-encounter` | A | HPI from live encounter transcript |
| POST | `/generate-hpi-dictation` | A | HPI from dictation |
| POST | `/generate-soap` | A | SOAP note |
| POST | `/generate-hospital-course` | A | Hospital course summary |
| POST | `/generate-chart-review` | A | Chart review |
| POST | `/generate-milestone-narrative` | A | Milestone narrative |
| POST | `/generate-milestone-summary` | A | 3-sentence milestone summary |
| POST | `/well-visit/note` | A | Full well-visit note |
| POST | `/sick-visit/note` | A | Sick visit SOAP |
| POST | `/transcribe` | A | Whisper audio → text (multipart/form-data, field: `audio`) |
| POST | `/refine` | A | Refine existing document |
| POST | `/shorten` | A | Shorten existing document |
| POST | `/clarify` | A | Find missing info in a document |
### Encounters (Save/Load)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/encounters` | A | List user's saved encounters |
| POST | `/encounters` | A | Save/update encounter draft |
| DELETE | `/encounters/:id` | A | Delete a draft |
### User Templates
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/memories` | A | List user's templates |
| POST | `/memories` | A | Create template |
| PUT | `/memories/:id` | A | Update template |
| DELETE | `/memories/:id` | A | Delete template |
| GET | `/memories/context` | A | Returns templates formatted for AI injection |
### Nextcloud
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/nextcloud/connect` | A | Connect + test Nextcloud credentials |
| POST | `/nextcloud/export` | A | Export text file to Nextcloud |
| POST | `/nextcloud/disconnect` | A | Remove Nextcloud credentials |
### Learning Hub (User-Facing)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/learning/categories` | A | List categories |
| GET | `/learning/feed` | A | Paginated published content |
| GET | `/learning/category/:slug` | A | Content by category |
| GET | `/learning/content/:slug` | A | Single content item + questions |
| POST | `/learning/submit-quiz` | A | Submit quiz answers, returns scored results |
| GET | `/learning/search` | A | Full-text search |
### Learning Hub CMS (Moderator+)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/admin/learning/categories` | MOD | All categories with counts |
| POST | `/admin/learning/categories` | MOD | Create category |
| PUT | `/admin/learning/categories/:id` | MOD | Update category |
| DELETE | `/admin/learning/categories/:id` | MOD | Delete category |
| GET | `/admin/learning/content` | MOD | All content (including drafts) |
| GET | `/admin/learning/content/:id` | MOD | Single item with questions |
| POST | `/admin/learning/content` | MOD | Create content |
| PUT | `/admin/learning/content/:id` | MOD | Update content |
| DELETE | `/admin/learning/content/:id` | MOD | Delete content + questions |
| POST | `/admin/learning/content/:id/questions` | MOD | Add question to content |
| PUT | `/admin/learning/questions/:id` | MOD | Update question + options |
| DELETE | `/admin/learning/questions/:id` | MOD | Delete question |
| GET | `/admin/learning/stats` | MOD | Dashboard stats |
### Learning Hub AI (Moderator+)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/admin/learning/ai-generate` | MOD | Generate content from topic/file/Nextcloud (multipart/form-data) |
| POST | `/admin/learning/ai-refine` | MOD | Refine body HTML with instructions |
| POST | `/admin/learning/preview-slides` | MOD | Render Marp markdown → `{ css, slides[] }` for preview |
| POST | `/admin/learning/generate-pptx` | MOD | Marp markdown → `.pptx` download (pptxgenjs) |
| GET | `/admin/learning/webdav-browse` | MOD | PROPFIND Nextcloud folder |
| POST | `/admin/learning/webdav-path` | MOD | Save user's default WebDAV path |
### Admin (Admin Only)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/admin/users` | ADM | List all users |
| POST | `/admin/users` | ADM | Create user |
| PUT | `/admin/users/:id` | ADM | Update user (role, disable) |
| DELETE | `/admin/users/:id` | ADM | Delete user |
| GET/POST | `/admin/config/*` | ADM | Site settings (announcement, SMTP, models, prompts, etc.) |
### Logs & Health
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | — | Returns `{ status: 'running', version, provider }` |
| GET | `/models` | — | Returns available AI models list |
| POST | `/logs/client-error` | — | Client-side error logging (public) |
| GET | `/logs/usage` | ADM | API usage log |
| GET | `/logs/audit` | ADM | Audit log |
---
## 8. Frontend Architecture
### Tab Loading (Lazy Components)
Every tab's HTML lives in `/public/components/<tabname>.html`. When a tab button is clicked, `loadComponent()` in `app.js` fetches the HTML, injects it into the tab section, then fires `tabChanged` event.
```javascript
// app.js
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
```
**Critical pattern:** Every JS module that needs to access tab DOM elements MUST listen for `tabChanged`, not `DOMContentLoaded`:
```javascript
// Correct pattern for every tab module
(function() {
var _inited = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail.tab !== 'myTab' || _inited) return;
_inited = true;
// Now safe to querySelector elements — they exist in the DOM
var btn = document.getElementById('my-btn');
btn.addEventListener('click', ...);
});
})();
```
If you use `DOMContentLoaded` instead, the elements won't exist yet (they're loaded async) and you'll get `null.addEventListener` errors.
### Global Functions (defined in app.js)
These are available everywhere — no imports needed:
| Function | Description |
|----------|-------------|
| `getAuthHeaders()` | Returns `{ 'Content-Type': 'application/json', 'Authorization': 'Bearer <token>' }` |
| `getSelectedModel()` | Returns model ID from active tab's selector or global selector |
| `showLoading(msg)` | Shows full-screen loading overlay |
| `hideLoading()` | Hides loading overlay |
| `showToast(msg, type)` | Shows toast notification. `type`: `'success'`\|`'error'`\|`'info'`\|`'warning'` |
| `setOutputText(el, text)` | Sets text on contenteditable div, converting `\n` to `<br>` |
| `transcribeAudio(blob)` | Sends audio blob to `/api/transcribe`, returns `{ success, text }` |
| `createSpeechRecognition()` | Returns Web Speech API recognition instance |
| `createTimer(el)` | Returns timer object with `.start()` / `.stop()` |
### Rich Text Editor (Tiptap)
The body editor in the CMS uses Tiptap 2 (headless, no styling framework). The bundle is pre-built at `/public/vendor/tiptap.bundle.js` and exposes `window.Tiptap = { Editor, StarterKit, Link, Underline, TextStyle, Color }`.
To rebuild the bundle after updating Tiptap packages:
```bash
cat > tiptap-entry.js << 'EOF'
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Underline from '@tiptap/extension-underline';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
window.Tiptap = { Editor, StarterKit, Link, Underline, TextStyle, Color };
EOF
npx esbuild tiptap-entry.js --bundle --format=iife --minify --outfile=public/vendor/tiptap.bundle.js
rm tiptap-entry.js
```
---
## 9. AI Integration
### `src/utils/ai.js``callAI(messages, options)`
The single function used by all routes. It routes to the correct provider automatically.
```javascript
const { callAI } = require('../utils/ai');
const result = await callAI(
[{ role: 'user', content: 'Generate a note...' }],
{
model: 'google/gemini-2.5-flash', // optional, uses default if omitted
temperature: 0.3, // optional, default 0.3
maxTokens: 4000 // optional, default 4000
}
);
// result = { success: true, content: '...', model: '...', provider: '...', duration: ms }
```
### Bedrock Model Notes
**Inference Profiles:** Most newer models (Anthropic vendor model 4.x, Meta Llama 4, DeepSeek R1, Amazon Nova, Writer) require cross-region inference profiles. These use a `us.` prefix on the model ID (e.g. `us.anthropic.agent-config-sonnet-4-6`). Direct model IDs will return "on-demand throughput not supported" errors.
**Max Output Tokens:** Some models have low output limits (Cohere Command R/R+: 4096, AI21 Jamba: 4096). The `maxOut` field in `models.js` auto-clamps `maxTokens` in `callBedrock()`.
**JSON Sanitization:** Some models (notably vendor model Sonnet 4.6, Opus 4.6) output literal newline characters inside JSON string values. `learningAI.js` includes a `sanitizeJsonString()` function that escapes these before parsing.
### Prompt System
Prompts are defined in `src/utils/prompts.js`. Admins can override any prompt via the Admin panel (`/admin/config/prompts`). Overrides are stored in `app_settings` table and loaded into memory on startup (with 3s grace period for DB readiness).
To add a new prompt:
1. Add a default in `prompts.js`
2. Use `PROMPTS.get('your-prompt-key')` in your route
3. The admin panel will auto-discover it
### AI Generate for Learning Hub
The `src/routes/learningAI.js` file handles all Learning Hub AI generation.
**For presentations:** The AI is prompted to return raw Marp markdown (not JSON). The response is stored in the `body` column. Detection: `content_type === 'presentation'`.
**For articles/quizzes/pearls:** The AI returns JSON:
```json
{
"title": "...",
"subject": "...",
"body": "<p>HTML content</p>",
"questions": [
{
"question_text": "...",
"question_type": "mcq",
"explanation": "...",
"options": [
{ "option_text": "...", "is_correct": true, "explanation": "..." }
]
}
]
}
```
---
## 10. Learning Hub & CMS
### Content Types
| Type | Body format | Has questions |
|------|-------------|---------------|
| `article` | HTML (Tiptap) | Optional |
| `quiz` | HTML (brief intro) | Always |
| `pearl` | HTML | Optional |
| `presentation` | Marp markdown | Never |
### Quiz Question Types
- `mcq` — Single choice (radio buttons), 4 options, 1 correct
- `true_false` — 2 options: "True" / "False", 1 correct
- `multi` — Multiple select (checkboxes), scoring: all correct chosen AND no incorrect chosen
### PPTX Generation
`POST /admin/learning/generate-pptx` parses Marp markdown (splits on `---`), extracts `#` headings as slide titles, bullet points as content, and uses `pptxgenjs` to create a real `.pptx`. **No Chromium required** — pure Node.js.
### Slide Preview
`POST /admin/learning/preview-slides` uses `@marp-team/marp-core` to render Marp markdown to HTML, then extracts individual `<section>` elements. Returns `{ css, slides[] }`. The frontend renders these one at a time in a full-screen modal with arrow key + swipe navigation.
### Content Display
In the Learning Hub viewer, content `body` is rendered via `sanitizeHtml()` in `learningHub.js`. This function allows a safe subset of HTML tags only (no `<script>`, no `on*` attributes, no `style` attributes except `class`).
---
## 11. Deployment
### Local Development
```bash
cp .env.example .env # Fill in your credentials
docker compose -f docker-compose.local.yml build --no-cache
docker compose -f docker-compose.local.yml up -d
# App runs at http://localhost:3552
```
### Logs & Debugging
**View container logs (live):**
```bash
docker logs -f pediatric-ai-scribe
```
**View last N lines:**
```bash
docker logs --tail 50 pediatric-ai-scribe
```
**Filter for specific issues:**
```bash
# AI/Bedrock errors
docker logs pediatric-ai-scribe 2>&1 | grep -i "Bedrock\|LearningAI\|callAI"
# Auth errors
docker logs pediatric-ai-scribe 2>&1 | grep -i "Auth\|login\|verify"
# All errors
docker logs pediatric-ai-scribe 2>&1 | grep -i "error\|ERR\|fail"
```
**Key log prefixes:**
| Prefix | Source |
|--------|--------|
| `[Bedrock] Model:` | AI response metadata (block types, stop reason) |
| `[LearningAI]` | JSON parse failures with raw output context |
| `[Auth]` | Login, registration, verification events |
| `[TTS]` | Text-to-speech generation |
| `🤖 Provider:` | Startup: which AI provider is active |
| `✅ AWS Bedrock:` | Startup: Bedrock configured successfully |
**Database logs (PostgreSQL):**
```bash
docker logs pedscribe-db
```
### Production (Docker Hub image)
```bash
# docker-compose.yml (production)
services:
app:
image: danielonyejesi/pediatric-ai-scribe-v3:latest
ports: ["3000:3000"]
env_file: .env
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: pedscribe
POSTGRES_USER: pedscribe
POSTGRES_PASSWORD: your_secure_password
volumes:
- pgdata:/var/lib/postgresql/data
```
### Docker Hub
Repository: `danielonyejesi/pediatric-ai-scribe-v3`
Tags use versioned format: `v5.0`, `v5.1`, etc. Production should always pin to a specific tag.
### Git Repository
Repository: `ifedan-ed/pediatric-ai-scribe-v3` (private)
### Build & Push Process
```bash
# 1. Test locally first
docker compose -f docker-compose.local.yml build --no-cache
docker compose -f docker-compose.local.yml up -d
# Test at http://localhost:3552
# 2. When ready, tag and push to Docker Hub
docker tag scribe-pediatric-scribe:latest danielonyejesi/pediatric-ai-scribe-v3:v5.x
docker push danielonyejesi/pediatric-ai-scribe-v3:v5.x
# 3. Update production docker-compose.yml to use new tag
```
---
## 12. Known Issues & Security Notes
### Active Known Issues
1. **`nodemailer` HIGH vulnerability** — v6.9.x has an email domain interpretation conflict. Upgrade to `^6.10.0` when available.
2. **`unsafe-inline` in CSP** — `scriptSrc` includes `'unsafe-inline'` to support inline event handlers in HTML components. Should migrate to event listeners and remove this directive.
3. **JWT in localStorage** — Tokens stored in `localStorage` are readable by JavaScript and therefore vulnerable to XSS attacks. A future migration to `httpOnly` cookies would eliminate this risk. See notes in auth.js and Section 6.
4. **`window.prompt()` in `runAiRefineBody`** — Uses browser native prompt, which can be blocked in certain contexts. Should be replaced with an inline input field.
5. **`webdav-learning-path` endpoint** — Sits behind `moderatorMiddleware` but is a user preference that non-moderator users might reasonably need. Consider moving to plain `authMiddleware`.
### Security Hardening Already In Place
- Helmet.js with custom CSP (no external script sources)
- CORS restricted to `APP_URL` in production
- Rate limiting on login (10/15min), register (5/hr), forgot-password (5/hr), resend-verification (3/15min), general API (60/min)
- bcrypt cost 12 for password hashing
- JWT with 7-day expiry
- SQL injection protection: all queries use parameterized `?` / `$1` placeholders
- Dynamic table names validated against an explicit allowlist (`ALLOWED_SLUG_TABLES`)
- User input in HTML contexts goes through `sanitizeHtml()` (tag allowlist, strips `on*` attributes)
- File upload MIME type validated by extension + content type
- Admin/moderator route protection via middleware
---
## 13. Adding New Features
### Adding a New Clinical Tab
1. Create `public/components/mytab.html` with the tab's UI
2. Add to `index.html`:
- Tab button: `<button class="tab-btn" data-tab="mytab">...</button>`
- Tab section: `<section id="mytab-tab" class="tab-content" data-component="mytab"></section>`
- Script tag: `<script defer src="/js/myTab.js"></script>`
3. Create `public/js/myTab.js`:
```javascript
(function() {
var _inited = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail.tab !== 'mytab' || _inited) return;
_inited = true;
// Wire up DOM elements here
});
})();
```
4. Create `src/routes/myTab.js` with the API route
5. Register in `server.js`: `app.use('/api', require('./src/routes/myTab'));`
### Adding a New AI Prompt
1. In `src/utils/prompts.js`, add to the defaults object:
```javascript
'my-prompt': 'You are a pediatric physician...'
```
2. In your route: `const prompt = PROMPTS.get('my-prompt') + '\n\n' + userInput`
3. The admin panel will show an editor for this prompt automatically.
### Adding a New Learning Hub Content Type
1. Add the new type to the `content_type` selector in `cms.html`
2. Handle it in `toggleEditorMode()` in `learningHub.js`
3. Add to the type detection in `buildGeneratePrompt()` in `learningAI.js`
4. Handle rendering in `learningHub.js` `loadContent()` function
5. No DB migration needed — `content_type` is a free-text column
---
## 14. Resetting Admin Password via Console
If you lose admin access and have no SMTP for password reset, use the Docker console:
```bash
# Step 1: Get a shell in the running app container
docker exec -it pediatric-ai-scribe sh
# Step 2: Open Node.js REPL
node
# Step 3: Hash your new password
const bcrypt = require('bcryptjs');
const hash = await bcrypt.hash('YourNewPassword123!', 12);
console.log(hash);
// Copy the hash output
# Step 4: Exit Node REPL
.exit
# Step 5: Open a DB shell
# (Exit app container first, then:)
docker exec -it pedscribe-db psql $POSTGRES_USER $POSTGRES_DB
# Step 6: Update the password (paste the hash)
UPDATE users
SET password = '$2a$12$...(your-hash-here)...'
WHERE email = 'your-admin@email.com';
# Verify:
SELECT email, left(password, 7) as hash_prefix FROM users WHERE email = 'your-admin@email.com';
# Exit:
\q
```
### Enabling Registration via Console
```bash
docker exec -it pedscribe-db psql $POSTGRES_USER $POSTGRES_DB
UPDATE app_settings SET value = 'true' WHERE key = 'registration_enabled';
\q
```
### Creating First Admin User (empty database)
The first user to register is automatically made admin. Enable registration, register, then disable registration again.
Or directly:
```bash
# In the Node REPL inside the app container:
const bcrypt = require('bcryptjs');
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const hash = await bcrypt.hash('YourPassword', 12);
await pool.query(
"INSERT INTO users (email, password, name, role, email_verified) VALUES ($1, $2, $3, 'admin', true)",
['admin@yourdomain.com', hash, 'Admin']
);
pool.end();
```
---
## 15. Version History (Recent)
| Tag | Key changes |
|-----|-------------|
| v3.19 | Login flash fixed (auth screen hidden by CSS default); presentation quiz option; feed labels corrected |
| v3.18 | pdf-parse downgraded to v1.1.1; WebDAV selection UX fixed; topic context on upload/WebDAV tabs; inline refine bar replaces window.prompt(); CSP: removed unsafe-inline (all onclick= converted to data-action delegation); webdav-path moved to /api/user/webdav-path (auth-only) |
| v3.17 | AI panel context-aware options fixed (style.display replaces classList — CSS cascade bug); quiz card redesign |
| v3.16 | DEVELOPER_GUIDE.md created |
| v3.15 | Auth reverted to localStorage tokens; slide preview padding fixed |
| v3.14 | AI panel context-aware options (word count, slide count, quiz toggle); delete wording per type |
| v3.13 | Delete confirm inline bar CSS bug fixed; slide preview in-page modal (arrow/swipe nav); Marp textarea placeholder |
| v3.12 | Delete inline confirm bar; lighter login screen; Presentation type (Marp + pptxgenjs PPTX) |
| v3.11 | AI content generation for Learning Hub (topic/file/Nextcloud, pdf-parse, pptxgenjs) |
| v3.10 | Custom 404 page; server returns 404 for unknown paths |
| v3.8 | Quill replaced with Tiptap 2 (self-hosted bundle, inline link bar) |
| v5.0 | Resend verification link on login + rate limit (3/15min) |
| v5.1v5.4 | Bedrock model fixes: inference profiles, region filtering, thinking block handling |
| v5.5 | Comprehensive Bedrock fix: all us. prefix IDs, maxTokens clamping |
| v5.6 | Re-add Qwen3 235B |
| v5.7 | Remove Opus 4.6 (JSON issues) |
| v5.8 | Fix JSON parse: sanitize literal newlines in strings; re-add Opus 4.6 |
| v5.9 | Re-add Opus 4.6 with sanitizer; updated DEVELOPER_GUIDE |
| v6.0 | Increase PDF/doc context to 50k chars; maxTokens ceiling to 8k |
## 16. Current Docker Image
**Latest stable:** `danielonyejesi/pediatric-ai-scribe-v3:v6.0`
```bash
docker pull danielonyejesi/pediatric-ai-scribe-v3:v6.0
```
---
## 17. PDF & Document Uploads
### How It Works
The Learning Hub AI generator accepts documents via two paths — both produce the same result:
1. **Direct upload** — user selects a file from their computer (up to 20 MB)
2. **Nextcloud WebDAV** — user browses their Nextcloud and picks a file
The flow:
1. `extractText()` in `learningAI.js` detects file type by MIME/extension
2. **PDF:** `pdf-parse` v1.1.1 extracts all text pages into a single string
3. **PPTX/DOCX/TXT:** extracted via appropriate parser or read as UTF-8
4. Text is truncated to **50,000 characters** (~25-30 pages) and sent as context in the AI prompt
5. AI generates structured content (title, HTML body, quiz questions) from the full context
### Supported File Types
| Extension | Handler | Notes |
|-----------|---------|-------|
| `.pdf` | `pdf-parse` | Extracts text only — images, charts, tables are lost |
| `.pptx` | Text extraction from slides | Slide text only |
| `.docx` | Text extraction | Body text only |
| `.txt`, `.md`, `.csv` | Read as UTF-8 | Full content preserved |
### Limits
- **Upload size:** 20 MB (`multer` limit in `learningAI.js`)
- **Context sent to AI:** 50,000 characters (configurable in `buildGeneratePrompt()`)
- **AI response tokens:** 8,000 max (ceiling — model stops when done)
### Why No Vector Embeddings / RAG
Embeddings and RAG (Retrieval Augmented Generation) are unnecessary for this use case:
- **Single document → single generation** — the full text fits in the model's context window
- Most Bedrock models support 100K-200K token inputs — 50,000 chars is well within that
- Embeddings would add complexity (pgvector, chunking, retrieval pipeline) with no benefit
If you later need to **search across hundreds of stored documents** or handle 200+ page PDFs, then consider pgvector + chunked retrieval. For now, the direct approach is correct.
---
## 18. Scalability
### Current Architecture (Single Instance)
The app runs as a single Node.js process. This is fine for a team/department deployment (tens to hundreds of concurrent users).
### What Scales Well Already
- **Stateless JWT auth** — no server-side session store; any instance can validate any token
- **PostgreSQL** — handles concurrent connections well; supports read replicas
- **Lazy-loaded component HTML** — reduces initial page size; tabs load on demand
- **AI calls** — fully async; expensive calls don't block other requests
### Bottlenecks to Address Before Horizontal Scaling
| Issue | Current | Fix for multi-instance |
|-------|---------|----------------------|
| Rate limiting | In-memory (per process) | Replace with Redis (`rate-limit-redis`) |
| File uploads | `multer` in RAM | Route uploads to S3/object storage |
| Scheduled cleanup | `setTimeout` in server.js | Use a dedicated cron job or DB-scheduled task |
### How to Scale Horizontally
```yaml
# docker-compose with 3 app replicas + nginx load balancer
services:
app:
image: danielonyejesi/pediatric-ai-scribe-v3:latest
deploy:
replicas: 3
environment:
DATABASE_URL: postgresql://... # shared external Postgres
REDIS_URL: redis://redis:6379 # add when rate-limit-redis is wired
nginx:
image: nginx:alpine
# upstream: round-robin across app replicas
redis:
image: redis:7-alpine
postgres:
image: postgres:16-alpine
```
Cloud deployment options (all work with the current Docker image):
- **AWS ECS/Fargate** — managed containers, easy auto-scaling
- **Railway / Render / Fly.io** — simple push-to-deploy with Docker
- **Kubernetes** — full control, overkill for most deployments
---
## 19. Security Architecture — localStorage vs httpOnly Cookies
The app stores JWT tokens in `localStorage`. This is a deliberate choice appropriate for this scale. The key security facts:
**Current protections in place (more important than storage location):**
- `Content-Security-Policy: script-src 'self'` — blocks all external scripts and inline JS (v3.18)
- Input sanitization via `sanitizeHtml()` allowlist on all user-generated HTML
- All 26 `onclick=` inline event handlers removed (v3.18) — reduces XSS surface
- Rate limiting on auth endpoints
- Helmet.js security headers
- Parameterized SQL queries throughout
**The reality about localStorage vs httpOnly cookies:**
> "Unless you're a bank or large enterprise, it doesn't really matter. Focus on preventing XSS, because that's what actually matters... fundamentally, the security benefit of using httpOnly cookies is very minimal. If your site suffers any kind of XSS, it makes it slightly more difficult for an attacker to use the auth token." — Security engineering community consensus
httpOnly cookies prevent token *copying* but not token *use* — an XSS attacker can still make authenticated requests on the user's behalf regardless of where the token is stored.
**If you later want httpOnly cookies:** The infrastructure is already in place (cookie-parser, CORS `credentials:true`). The change is: (1) set cookie on login, (2) remove token from `getAuthHeaders()`, (3) add `/api/auth/logout` to clear cookie. See notes in `auth.js`. This was implemented and reverted in v3.14 — it works but adds CSRF considerations.
**Token lifetime:** Currently 7 days. For higher security, reduce to 1-2 hours and add refresh token rotation.
---
---
*Last updated: March 2026 — v6.0*
*Generated for developer handover.*

View file

@ -37,10 +37,10 @@ src/
fileType.js magic-byte upload verifier fileType.js magic-byte upload verifier
errors.js generic 500 responder errors.js generic 500 responder
logger.js audit + api + access + Loki shipper logger.js audit + api + access + Loki shipper
embeddings.js Vertex / LiteLLM / OpenAI embeddings embeddings.js LiteLLM embeddings
notify.js ntfy push notify.js ntfy push
transcribe*.js, tts*.js STT / TTS provider clients transcribe.js, tts.js LiteLLM STT / TTS routes
routes/ 27 routers routes/ Express routers for auth, AI workflows, education, logs, and user data
public/ public/
index.html SPA shell, version-stamped asset refs index.html SPA shell, version-stamped asset refs
@ -49,7 +49,7 @@ public/
js/ 24 vanilla JS modules (no bundler) js/ 24 vanilla JS modules (no bundler)
components/ per-tab HTML fragments loaded on demand components/ per-tab HTML fragments loaded on demand
css/styles.css css/styles.css
models/ bundled Whisper WASM template-guide.md downloadable user template guide
mobile/ Capacitor 6 wrapper (Android + iOS) mobile/ Capacitor 6 wrapper (Android + iOS)
.github/workflows/ CI (auto-version, APK, docker) .github/workflows/ CI (auto-version, APK, docker)
@ -243,24 +243,19 @@ docker exec -w /app pediatric-ai-scribe npm run migrate:new -- add_my_table
3. Admin-editable automatically through `PUT /api/admin/config` which accepts 3. Admin-editable automatically through `PUT /api/admin/config` which accepts
arbitrary keys. arbitrary keys.
## Physician memory / correction tracker ## Physician Templates And Preferences
1. On note generation, `trackAIOutput(elementId, text)` captures the original 1. Settings saves user templates/preferences through `/api/memories` into
output in memory. `user_memories`.
2. User edits the note in a contenteditable field. 2. New rows encrypt `name` and `content` with the shared `enc1:` string format.
3. On Save, `saveCorrection(elementId, section)` diffs current vs. original. 3. `GET /api/memories/context` decrypts rows and returns only AI-context
4. If changed by > 2 words or > 20 characters, `POST /api/memories/correction` categories: `physical_exam`, `ros`, `encounter_format`, `family_history`,
stores the before/after in `user_memories` with category `assessment_plan`, `template_soap`, `template_hpi`, `template_wellvisit`,
`correction_{section}`. `template_sickvisit`, and `template_ed`.
5. Next generation: `GET /api/memories/context` fetches the 10 most recent per 4. `custom` rows remain visible in settings but are not included in prompt
category and `src/utils/prompts.js` injects them as context.
`[STYLE HINTS (low priority)]` 200-character snippets. 5. Legacy `correction_*` rows from the removed correction-learning feature are
filtered out rather than deleted.
Tabs with correction capture: Live Encounter, SOAP, Dictation, Sick Visit,
Well Visit (Hospital Course and Chart Review save corrections when available
but don't always have a trackable single output element).
Maximum 20 corrections retained per category (oldest deleted).
## Route reference ## Route reference
@ -277,10 +272,10 @@ Maximum 20 corrections retained per category (oldest deleted).
| `sickVisit.js` | `/api` | Auth | Sick visit | | `sickVisit.js` | `/api` | Auth | Sick visit |
| `milestones.js` | `/api` | Auth | Developmental milestone narratives | | `milestones.js` | `/api` | Auth | Developmental milestone narratives |
| `refine.js` | `/api` | Auth | Refine / shorten / clarify | | `refine.js` | `/api` | Auth | Refine / shorten / clarify |
| `transcribe.js` | `/api` | Auth | STT (5 providers) | | `transcribe.js` | `/api` | Auth | LiteLLM STT |
| `tts.js` | `/api` | Auth | TTS (3 providers) | | `tts.js` | `/api` | Auth | LiteLLM TTS |
| `encounters.js` | `/api` | Auth | Save / load / optimistic-lock encounters | | `encounters.js` | `/api` | Auth | Save / load / optimistic-lock encounters |
| `memories.js` | `/api` | Auth | Templates + corrections | | `memories.js` | `/api` | Auth | Templates + prompt preferences |
| `audioBackups.js` | `/api` | Auth | Encrypted audio retry store | | `audioBackups.js` | `/api` | Auth | Encrypted audio retry store |
| `documents.js` | `/api` | Auth | S3 documents (magic-byte checked) | | `documents.js` | `/api` | Auth | S3 documents (magic-byte checked) |
| `userPreferences.js` | `/api` | Auth | Per-user STT/TTS choice | | `userPreferences.js` | `/api` | Auth | Per-user STT/TTS choice |
@ -307,10 +302,8 @@ Maximum 20 corrections retained per category (oldest deleted).
| `milestones.js` + `milestonesData.js` | Milestones tab | | `milestones.js` + `milestonesData.js` | Milestones tab |
| `shadess.js` | SSHADESS adolescent assessment | | `shadess.js` | SSHADESS adolescent assessment |
| `encounters.js` | Save / load / resume with optimistic lock | | `encounters.js` | Save / load / resume with optimistic lock |
| `memories.js` | Physician templates + corrections UI | | `memories.js` | Physician templates and prompt preferences UI |
| `correctionTracker.js` | Captures AI-output edits | | `speechRecognition.js` | Explicit opt-in browser Web Speech support |
| `browserWhisper.js` | In-browser WASM Whisper |
| `speechRecognition.js` | Web Speech API preview |
| `voicePreferences.js` | Per-user STT/TTS override | | `voicePreferences.js` | Per-user STT/TTS override |
| `audioBackup.js` | Server + IndexedDB backup retries | | `audioBackup.js` | Server + IndexedDB backup retries |
| `nextcloud.js` | Connect / export | | `nextcloud.js` | Connect / export |

View file

@ -1,8 +1,8 @@
# Embeddings & Semantic Search Setup # Embeddings And Semantic Search Setup
This guide explains how to set up and use the new vector-based semantic search for the Learning Hub. This guide explains how to set up and use the new vector-based semantic search for the Learning Hub.
## 🎯 What's New ## What This Enables
- **Semantic search** - Find content by meaning, not just keywords - **Semantic search** - Find content by meaning, not just keywords
- **3 search modes**: - **3 search modes**:
@ -10,9 +10,9 @@ This guide explains how to set up and use the new vector-based semantic search f
- **Semantic** (`/api/learning/search/semantic`) - AI-powered vector similarity - **Semantic** (`/api/learning/search/semantic`) - AI-powered vector similarity
- **Hybrid** (`/api/learning/search/hybrid`) - Combines both for best results - **Hybrid** (`/api/learning/search/hybrid`) - Combines both for best results
- **Auto-embedding** - Content is automatically vectorized when created/updated - **Auto-embedding** - Content is automatically vectorized when created/updated
- **HIPAA-compliant** - Uses Vertex AI embeddings (BAA available) - **Gateway-routed** - Uses LiteLLM embeddings so provider policy stays in one place
## 📋 Prerequisites ## Prerequisites
### 1. Install pgvector Extension ### 1. Install pgvector Extension
@ -37,39 +37,24 @@ postgres:
# ... rest of your config # ... rest of your config
``` ```
### 2. Configure Embedding Provider ### 2. Configure LiteLLM Embeddings
Add to your `.env` file: Add to your `.env` file:
```bash ```bash
# Option 1: Vertex AI (HIPAA-eligible, recommended)
EMBEDDING_MODEL=vertex_ai/text-embedding-005
EMBEDDING_DIMENSIONS=768
VERTEX_PROJECT=your-gcp-project-id
VERTEX_LOCATION=us-central1
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
# Option 2: LiteLLM Proxy (routes to any provider)
LITELLM_API_BASE=http://localhost:4000 LITELLM_API_BASE=http://localhost:4000
LITELLM_API_KEY=your-key LITELLM_API_KEY=your-key
EMBEDDING_MODEL=text-embedding-005 # LiteLLM will route to configured provider EMBEDDING_MODEL=openai-text-embedding-3-large
EMBEDDING_DIMENSIONS=3072
# Option 3: OpenAI (NOT HIPAA-eligible, fallback only)
OPENAI_API_KEY=sk-your-key
# Uses text-embedding-3-small automatically
``` ```
## 🚀 Available Vertex AI Embedding Models ## Available Embedding Models
Tested and working via LiteLLM: The Admin embedding search reads LiteLLM `/model/info` and only shows models with `model_info.mode = "embedding"`. Do not add app-side built-in Vertex/OpenAI embedding lists; configure those choices in LiteLLM.
| Model | Dimensions | Use Case | HIPAA | The local LiteLLM instance currently exposes examples such as `openai-text-embedding-3-large`, `openai-text-embedding-3-small`, and Mistral embedding models. Dimensions are read from LiteLLM metadata when available.
|-------|-----------|----------|-------|
| **vertex_ai/text-embedding-005** | 768 | English + code (recommended) | ✅ Yes |
| **vertex_ai/gemini-embedding-001** | 768-3072 | Multilingual + code, best quality | ✅ Yes |
| **vertex_ai/text-multilingual-embedding-002** | 768 | Multilingual focus | ✅ Yes |
## 🔧 Setup Steps ## Setup Steps
### 1. Database Migration ### 1. Database Migration
@ -113,12 +98,12 @@ Response:
"total": 50, "total": 50,
"withEmbeddings": 50, "withEmbeddings": 50,
"missing": 0, "missing": 0,
"model": "vertex_ai/text-embedding-005", "model": "openai-text-embedding-3-large",
"dimensions": 768 "dimensions": 3072
} }
``` ```
## 🔍 Using Semantic Search ## Using Semantic Search
### Keyword Search (existing) ### Keyword Search (existing)
```bash ```bash
@ -144,12 +129,12 @@ GET /api/learning/search/hybrid?q=fever management
``` ```
Combines keyword + semantic for best results. Automatically deduplicates and ranks by relevance. Combines keyword + semantic for best results. Automatically deduplicates and ranks by relevance.
## 🔬 How It Works ## How It Works
1. **Content Creation/Update**: 1. **Content Creation/Update**:
- Text is extracted from `title`, `subject`, and `body` (HTML stripped) - Text is extracted from `title`, `subject`, and `body` (HTML stripped)
- Sent to embedding model (Vertex AI) - Sent to the configured LiteLLM embedding model
- Returns 768-dimensional vector - Returns an embedding vector
- Stored in `learning_content.embedding` column - Stored in `learning_content.embedding` column
2. **Semantic Search**: 2. **Semantic Search**:
@ -164,35 +149,23 @@ Combines keyword + semantic for best results. Automatically deduplicates and ran
- Deduplicates by content ID - Deduplicates by content ID
- Sorts by relevance score - Sorts by relevance score
## 💰 Cost Estimate (Vertex AI) ## Cost Estimate
**Titan Text Embeddings (AWS) pricing:** Embedding cost depends on the upstream configured in LiteLLM.
- ~$0.10 per 1M tokens
- Average article: 2,000 words (~2,700 tokens) = $0.00027
- 1,000 articles: ~**$0.27 one-time**
- Search queries: ~500 tokens = $0.00005 per query
**Google Vertex AI pricing:** ## Troubleshooting
- text-embedding-005: $0.025 per 1M characters
- Average article: 10,000 chars = $0.00025
- 1,000 articles: ~**$0.25 one-time**
- Search queries: ~$0.0000125 per query
## 🐛 Troubleshooting
### "pgvector extension not available" ### "pgvector extension not available"
- Install: `apt-get install postgresql-16-pgvector` - Install: `apt-get install postgresql-16-pgvector`
- For Docker: Use `pgvector/pgvector:pg16` image - For Docker: Use `pgvector/pgvector:pg16` image
### "Embeddings not configured" ### "Embeddings not configured"
- Verify `.env` has `VERTEX_PROJECT` or `LITELLM_API_BASE` or `OPENAI_API_KEY` - Verify `.env` has `LITELLM_API_BASE`
- Check service account credentials: `GOOGLE_APPLICATION_CREDENTIALS`
- Test: `curl http://localhost:3000/api/admin/learning/embeddings/status` - Test: `curl http://localhost:3000/api/admin/learning/embeddings/status`
### "Embedding generation failed" ### "Embedding generation failed"
- Check logs for API errors - Check logs for API errors
- Verify Vertex AI API is enabled in GCP - Verify LiteLLM `/model/info` shows the selected model with `mode: embedding`
- Verify service account has `aiplatform.endpoints.predict` permission
- Check content isn't empty (skips empty bodies) - Check content isn't empty (skips empty bodies)
### "No results from semantic search" ### "No results from semantic search"
@ -200,23 +173,23 @@ Combines keyword + semantic for best results. Automatically deduplicates and ran
- Lower threshold: `?threshold=0.3` (default 0.5) - Lower threshold: `?threshold=0.3` (default 0.5)
- Verify pgvector index exists: `\di` in psql - Verify pgvector index exists: `\di` in psql
## 📊 Performance ## Performance
- **Embedding generation**: ~500ms per article (Vertex AI) - **Embedding generation**: latency depends on the LiteLLM upstream
- **Search latency**: - **Search latency**:
- Keyword: 10-50ms - Keyword: 10-50ms
- Semantic: 20-100ms (with IVFFLAT index) - Semantic: 20-100ms (with IVFFLAT index)
- Hybrid: 30-150ms - Hybrid: 30-150ms
- **Index build time**: ~1-5 seconds per 1,000 articles - **Index build time**: ~1-5 seconds per 1,000 articles
## 🔐 Security & Compliance ## Security And Compliance
- **HIPAA-eligible**: Vertex AI supports BAA (Business Associate Agreement) - **Compliance**: controlled by the upstream provider configured in LiteLLM
- **Data retention**: Embeddings stored in your database only - **Data retention**: Embeddings stored in your database only
- **No PHI**: Only article content (not patient data) is embedded - **No PHI**: Only article content (not patient data) is embedded
- **Encryption**: TLS in transit, at-rest encryption via PostgreSQL - **Encryption**: TLS in transit, at-rest encryption via PostgreSQL
## 🎓 Example Queries ## Example Queries
**Before (keyword):** **Before (keyword):**
``` ```
@ -244,7 +217,7 @@ Results:
- Bronchiolitis vs asthma (keyword: 1.0) - Bronchiolitis vs asthma (keyword: 1.0)
``` ```
## 📚 API Reference ## API Reference
### Admin Endpoints ### Admin Endpoints

View file

@ -1,347 +1,81 @@
# Features Explained - Pediatric AI Scribe v14 # Features Explained
## 🎙️ **Audio Backups** This file is a practical operator-oriented overview of major Ped-AI features. It intentionally describes the current fork, not historical browser Whisper behavior.
### How It Works: ## Clinical Documentation
Audio backups happen **automatically every time you record**, regardless of transcription success/failure.
**Flow:** Ped-AI generates pediatric clinical notes from typed input, dictation, or recorded audio. Major workflows include live encounters, dictation cleanup, sick visits, well visits, SOAP notes, hospital courses, chart review, ED documentation, and developmental milestones.
1. You press "Stop" on recording
2. Audio is immediately saved **before** transcription starts
3. Server-side backup (PostgreSQL, gzip compressed) attempted first
4. If server fails → fallback to browser IndexedDB
5. After successful transcription → audio backup is deleted
6. If transcription fails → audio backup remains for retry
**Location:** Model selection is available per task where the UI exposes a tab-level selector. Admin defaults provide the baseline model and user/task choices can override that baseline.
- Server: PostgreSQL `audio_backups` table (auto-deleted after 24 hours)
- Browser: IndexedDB `PedScribeAudioBackup` database (manual cleanup)
**Purpose:** Generated notes can expose post-note helper panels. Billing suggestions and don't-miss review are clinician-facing. Patient education handouts are parent-facing drafts generated from the edited note, with optional diagnosis, medication, and preferred-language context. The clinician must verify the handout before sharing it.
- Retry transcription if it fails
- Recover audio if browser crashes
- Audit trail (24 hour retention)
**Access:** ## Phone Extensions And Pagers
Settings → Audio Backups section shows:
- Date/time of recording
- Module (encounter, dictation, etc.)
- File size
- "Retry Transcription" button (if transcription failed)
- "Delete" button
**Cost:** The bedside tools include a per-user phone extension and pager directory. Entries support active/trash views, search, soft delete/restore, permanent purge, ZIP export, and JSON/ZIP import. Import preview flags exact active duplicates, exact trashed matches that can be restored, and possible duplicates before committing changes.
Server backups are compressed (gzip) to ~1/10 original size. A 2MB recording becomes ~200KB in database.
--- ## Speech
## 🌐 **S3 Document Storage** Final transcription is server-side through LiteLLM. Configure upstream STT providers in LiteLLM rather than in Ped-AI.
### How It Works: Browser-native Web Speech is only an explicit opt-in preview path. It is not the final clinical transcript and may use browser-vendor cloud services.
Upload documents (PDFs, images, Word docs, text files) to S3-compatible storage.
**Supported Providers:** Browser Whisper and browser-local model workers are removed. Do not expect a pre-download model button, public Whisper worker, or bundled Xenova model path.
- AWS S3 (default)
- Backblaze B2
- MinIO (self-hosted)
- Any S3-compatible service
**Configuration (.env):** ## Text To Speech
```bash
# AWS S3 (uses Bedrock credentials if available)
S3_BUCKET=your-bucket-name
S3_REGION=us-east-1
S3_PREFIX=documents/ # Optional: folder prefix
# Backblaze B2 The voice preview button calls LiteLLM TTS and plays the returned audio in the browser. If preview is silent, check that a LiteLLM voice is selected, the gateway is configured, the user is authenticated, and browser autoplay has not blocked playback.
S3_BUCKET=your-bucket-name
S3_ENDPOINT=https://s3.us-west-004.backblazeb2.com
S3_REGION=us-west-004
S3_ACCESS_KEY_ID=your-b2-application-key-id
S3_SECRET_ACCESS_KEY=your-b2-application-key
# MinIO (self-hosted) ## Learning Hub
S3_BUCKET=your-bucket
S3_ENDPOINT=http://minio:9000
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=minio-access-key
S3_SECRET_ACCESS_KEY=minio-secret-key
S3_FORCE_PATH_STYLE=true # Required for MinIO
```
**Features:** Learning Hub is both a learner-facing content area and an admin/moderator CMS.
- ✅ 10 MB file size limit
- ✅ AES-256 server-side encryption
- ✅ Per-user folder organization (`documents/{userId}/{uuid}/filename`)
- ✅ Metadata stored in PostgreSQL (filename, mime type, size, description)
- ✅ Presigned URLs for secure access (1 hour expiry)
**Allowed File Types:** - Articles and pearls render sanitized content.
- PDF (`.pdf`) - Quizzes support single-answer, multi-select, and true/false questions.
- Images (`.jpg`, `.jpeg`, `.png`, `.gif`) - Presentations use Marp-style markdown with preview and PPTX export.
- Word documents (`.doc`, `.docx`) - AI generation can use topic text, uploaded source files, or connected Nextcloud WebDAV files.
- Text files (`.txt`, `.csv`) - Categories can organize content without deleting the content when category assignments change.
**Access:** ## Nextcloud WebDAV
Settings → Documents section
**Status Check:** Users can connect a Nextcloud account with an app password. Learning Hub AI generation can browse files from the connected WebDAV account, and users can set a default browse path to avoid repeatedly navigating to the same clinical content folder.
If S3 is not configured, the Documents section shows empty with message: "S3 not configured"
--- ## Documents And S3
## 📚 **Learning Hub - Default Browse Path** Document upload is optional and depends on S3-compatible storage configuration. Treat uploaded documents as PHI unless you have a separate deployment reason not to.
### What It Is: ## Audio Backups
A user preference that sets the **starting folder** when browsing Nextcloud files for AI content generation.
### When It's Used: Audio backups exist to recover failed transcription attempts.
Only in the **Learning Hub AI Content Generator** (Admin/Moderator feature).
**Scenario:** - They are created when transcription fails.
1. Admin/Moderator wants to create AI-generated learning content - They are encrypted before persistent storage.
2. They choose "Upload from Nextcloud" - They expire automatically.
3. File browser opens - Users can retry or delete them from Settings.
4. Instead of starting at root `/`, it opens at the configured path
**Example:** ## Admin Panel
```
Default path: /Medical-Resources
When you click "Browse Nextcloud", it opens:
/Medical-Resources/
├── Pediatric-Guidelines/
├── Clinical-Protocols/
└── Research-Papers/
Instead of: Admins can manage users, roles, registration, security settings, model defaults, prompts, logs, and Learning Hub content. Production deployments should enable SSO/2FA and restrict admin access.
/
├── Personal/
├── Photos/
├── Medical-Resources/ ← you'd have to navigate here every time
└── ...
```
**Configuration:** ## Feature Status
Settings → Nextcloud Integration → "Learning Hub — Default Browse Path"
**Examples:** | Feature | Status | Notes |
- `/Medical-Resources` - Opens in Medical Resources folder |---|---|---|
- `/Shared/Clinical-Content` - Opens in shared clinical content | Clinical note generation | Active | Provider depends on `AI_PROVIDER`. |
- `/` (empty) - Opens at root (default behavior) | Server transcription | Active | Google/AWS/LiteLLM/OpenAI paths. |
| Browser Web Speech preview | Optional | Explicit opt-in only. |
| Browser Whisper | Removed | No public worker or model download path. |
| Learning Hub CMS | Active | Articles, pearls, quizzes, presentations. |
| Nextcloud WebDAV | Active | Used for file browsing/content import. |
| Patient handouts | Active | Parent-facing, note-derived, preferred-language draft. |
| Extension transfer | Active | ZIP export plus JSON/ZIP import preview. |
| Audio backups | Active | Failure recovery only. |
| TTS preview | Active | Depends on configured provider. |
**Who Can Use This:** ## Troubleshooting
- Any authenticated user (not just moderators)
- It's a personal preference per user
- Only affects Learning Hub AI file picker
**Why This Exists:** - Check browser console for frontend errors.
If you store learning resources in a specific Nextcloud folder, you don't want to navigate there every single time you generate content. Set it once, it remembers. - Check `docker logs pediatric-ai-scribe -f` for backend errors.
- Check `/api/health` for service status.
--- - Check provider credentials and model names before debugging UI state.
- For Learning Hub file import failures, verify Nextcloud URL, username, app password, and folder path.
## 🎤 **Browser Whisper Pre-Download**
### Issue You Reported:
"Pre-download models works, stuck at starting download"
### What's Happening:
The download **is actually working** but progress updates are slow because:
1. HuggingFace CDN serves large files (39-244 MB)
2. Progress callbacks are not granular (reported per-file, not per-chunk)
3. Initial ONNX runtime download has no progress tracking
### Fixed:
- ✅ Added console logging to track progress
- ✅ Added 30-second timeout warning (doesn't stop download)
- ✅ Better error messages
### How to Test:
1. Open browser DevTools (F12) → Console tab
2. Click "Pre-download model"
3. Watch console for progress logs:
```
[BrowserWhisper] Starting preload...
[BrowserWhisper] Progress: onnx-runtime 0%
[BrowserWhisper] Progress: model.bin 23%
[BrowserWhisper] Progress: model.bin 47%
...
[BrowserWhisper] Progress: 100%
```
### Expected Download Times:
- **Tiny** (39 MB): 5-15 seconds (fast connection)
- **Base** (74 MB): 10-30 seconds
- **Small** (244 MB): 30-90 seconds
### If Still Stuck:
**Check these:**
1. Open DevTools → Network tab
2. Filter by "HuggingFace"
3. Look for downloads from `cdn-lfs-us-1.huggingface.co`
4. Check if files are actually downloading
**Common issues:**
- Slow internet connection (244 MB takes time!)
- Corporate firewall blocking HuggingFace CDN
- Browser IndexedDB quota exceeded
**Workaround:**
Just enable it and record audio - the model will download on first use (same as pre-download, but triggered automatically).
---
## 🔊 **TTS Voice Preview**
### Issue You Reported:
"Preview button next to TTS seems to do nothing"
### Fixed:
- ✅ Added error logging to console
- ✅ Better validation (checks for empty selection)
- ✅ Clear user feedback messages
### How to Use:
1. Go to Settings → Voice Preferences
2. Select a voice from "Text-to-Speech Voice" dropdown
3. Click "Preview" button
4. Wait 2-3 seconds
5. Audio should play automatically
### If Nothing Happens:
**Check browser console for errors:**
- Open DevTools (F12) → Console tab
- Click Preview
- Look for `[VoicePrefs] Preview error:` message
**Common issues:**
1. **No voice selected** → Select from dropdown first
2. **TTS not configured** → Check `.env` has `GOOGLE_VERTEX_PROJECT` or `LITELLM_API_BASE`
3. **Network error** → Check server logs for TTS API errors
4. **Browser autoplay policy** → Some browsers block autoplay, click page first
### Testing Checklist:
```bash
# 1. Check TTS is configured
curl http://localhost:3000/api/health | grep tts
# 2. Test TTS endpoint directly
curl -X POST http://localhost:3000/api/text-to-speech \
-H "Authorization: Bearer YOUR_JWT" \
-H "Content-Type: application/json" \
-d '{"text":"Test"}' \
--output test.mp3
# 3. Play the audio file
mpg123 test.mp3 # or open in browser
```
---
## 📋 **Summary of User Settings**
### Voice Preferences
**Location:** Settings → Voice Preferences (top section)
| Setting | Options | Default | Purpose |
|---------|---------|---------|---------|
| **STT Model** | gemini-2.0-flash-exp, gemini-2.0-flash, gemini-1.5-flash, gemini-1.5-pro, whisper-1 | Server default | Controls transcription accuracy |
| **TTS Voice** | Journey-F/D, Studio-O/M, Neural2 series, alloy, echo, fable, onyx, nova, shimmer | Server default | Controls read-aloud voice |
### Browser Whisper
**Location:** Settings → Browser Transcription (Local Whisper)
| Setting | Options | Default | Purpose |
|---------|---------|---------|---------|
| **Enable** | On/Off | Off | Local transcription (HIPAA-safe) |
| **Model** | Tiny, Base, Small | Tiny | Accuracy vs speed tradeoff |
### Nextcloud
**Location:** Settings → Nextcloud Integration
| Setting | Purpose |
|---------|---------|
| **Nextcloud URL** | Your Nextcloud instance |
| **Username** | Nextcloud username |
| **App Password** | Generate in Nextcloud → Security |
| **Default Browse Path** | Starting folder for Learning Hub AI picker |
### Documents (S3)
**Location:** Settings → Documents
Shows list of uploaded documents if S3 is configured. Upload limit: 10 MB per file.
### Audio Backups
**Location:** Settings → Audio Backups
Shows last 24 hours of recordings. Can retry transcription or delete.
---
## 🔧 **Troubleshooting Guide**
### Pre-Download Stuck
1. ✅ Open browser console (F12)
2. ✅ Look for `[BrowserWhisper] Progress:` logs
3. ✅ Check Network tab for HuggingFace downloads
4. ✅ Wait - 244 MB takes time!
5. ✅ If truly stuck (no network activity): refresh page, try again
### Preview Button Silent
1. ✅ Check voice is selected in dropdown
2. ✅ Open console for error messages
3. ✅ Test TTS endpoint directly (curl command above)
4. ✅ Check server logs for TTS provider errors
5. ✅ Verify `.env` has TTS provider configured
### S3 Not Working
1. ✅ Check `.env` has `S3_BUCKET` set
2. ✅ Verify credentials: `S3_ACCESS_KEY_ID` + `S3_SECRET_ACCESS_KEY`
3. ✅ Test bucket access from server:
```bash
aws s3 ls s3://your-bucket/ --region us-east-1
```
4. ✅ Check server logs for S3 errors when uploading
### Audio Backups Not Showing
1. ✅ Record audio first (they're created on recording, not transcription)
2. ✅ Check database: `SELECT COUNT(*) FROM audio_backups;`
3. ✅ Verify IndexedDB in browser: DevTools → Application → IndexedDB → `PedScribeAudioBackup`
4. ✅ Backups auto-delete after 24 hours
### Learning Hub Path Not Working
1. ✅ This only affects **AI content generator file picker**
2. ✅ It does NOT affect manual Nextcloud document browsing
3. ✅ Path must exist in your Nextcloud
4. ✅ Path format: `/Folder/Subfolder` (starts with `/`)
---
## 📊 **Feature Status Matrix**
| Feature | Status | Config Required | HIPAA-Safe | Notes |
|---------|--------|-----------------|------------|-------|
| **Audio Backups** | ✅ Working | None (auto) | ✅ Yes | Server + IndexedDB |
| **S3 Documents** | ✅ Working | S3_BUCKET | ✅ Yes (AWS) | Optional feature |
| **Browser Whisper** | ✅ Working | None (optional) | ✅ Yes | Client-side only |
| **Voice Preferences** | ✅ Working | Provider config | Depends | Google/AWS = yes |
| **Learning Hub Path** | ✅ Working | Nextcloud config | ✅ Yes | User preference |
| **TTS Preview** | ✅ Fixed | TTS provider | Depends | Check logs if fails |
| **Embeddings** | ✅ Working | Vertex/LiteLLM | ✅ Yes | Requires pgvector |
---
## 🚀 **Next Steps**
1. **Push v14 to Docker** (in progress via GitHub Actions)
2. **Test features after deployment**
3. **Check browser console for any errors**
4. **Verify TTS preview works with your provider**
5. **Test browser whisper download with different models**
---
**Questions? Check the logs:**
- Browser: F12 → Console tab
- Server: `docker logs pediatric-ai-scribe -f`
- Database: `psql -d pedscribe -c "SELECT COUNT(*) FROM audio_backups;"`

View file

@ -52,7 +52,7 @@ This is the highest-impact improvement for adoption but also the most complex to
### 5. Offline Mode ### 5. Offline Mode
**Current state:** The app requires an internet connection for AI generation and cloud-based transcription. Browser Whisper works offline for transcription only. **Current state:** The app requires configured server-side providers for AI generation and final transcription. Browser Whisper has been removed from the runtime.
**Improvement:** Add a local AI model option (e.g., a small medical LLM running on the device or local server) so the entire workflow — record, transcribe, generate note — can happen without any network calls. This would be valuable for: **Improvement:** Add a local AI model option (e.g., a small medical LLM running on the device or local server) so the entire workflow — record, transcribe, generate note — can happen without any network calls. This would be valuable for:
- Rural clinics with unreliable internet - Rural clinics with unreliable internet
@ -74,9 +74,9 @@ Each specialty has unique documentation requirements that could be addressed wit
### 7. Billing Code Suggestions ### 7. Billing Code Suggestions
**Current state:** The well visit tab includes some billing code references. **Current state:** Post-note billing suggestions are active as clinician-facing helper panels on supported note outputs.
**Improvement:** Automatically suggest ICD-10 and CPT codes based on the generated note content. After the AI generates a note, it could analyze the diagnoses, procedures, and visit complexity to suggest appropriate billing codes. This saves time on coding and reduces missed charges. **Further improvement:** Improve payer-specific rules, add institution-specific favorites, and add export formats that match common EHR coding workflows.
### 8. Quality Metrics Dashboard ### 8. Quality Metrics Dashboard
@ -85,7 +85,7 @@ Each specialty has unique documentation requirements that could be addressed wit
**Improvement:** Add a dashboard showing: **Improvement:** Add a dashboard showing:
- Average note generation time by type - Average note generation time by type
- Most-used AI models and their accuracy (based on how often users edit the output) - Most-used AI models and their accuracy (based on how often users edit the output)
- Transcription accuracy metrics (if corrections are tracked) - Transcription quality metrics from explicit user feedback or retry outcomes
- Usage patterns by time of day and day of week - Usage patterns by time of day and day of week
- Cost tracking across AI providers - Cost tracking across AI providers
@ -93,9 +93,9 @@ This would help administrators optimize model selection and identify training op
### 9. Patient Education Materials ### 9. Patient Education Materials
**Current state:** The Learning Hub serves educational content to physicians. **Current state:** Patient education handouts are active as post-note helpers. Generated notes can open a Handout panel that creates a parent-facing plain-text draft from the clinician note, with optional diagnosis, medication, patient age, and preferred language context. The Learning Hub remains the physician-facing education/CMS area.
**Improvement:** Add a patient-facing education module that generates age-appropriate handouts based on the diagnosis. For example, after generating a note for a child with asthma, the app could produce a parent-friendly handout explaining the diagnosis, medications, and when to seek emergency care — in the parent's preferred language. **Further improvement:** Add handout templates, saved handout history, institution-approved language libraries, and printable/PDF export.
### 10. Multi-Language Support ### 10. Multi-Language Support
@ -140,7 +140,7 @@ This mirrors the real workflow in training institutions and group practices.
### 14. Template Library ### 14. Template Library
**Current state:** Physician memories and corrections provide some personalization. **Current state:** Physician templates and prompt preferences provide per-user personalization. Legacy correction-learning rows may exist but are no longer active behavior.
**Improvement:** Add a shared template library where physicians can create, share, and browse note templates: **Improvement:** Add a shared template library where physicians can create, share, and browse note templates:
- "My asthma follow-up template" - "My asthma follow-up template"
@ -182,7 +182,7 @@ Compared to existing medical scribes and documentation tools:
- **Pediatric-specific** — prompts, calculators, milestones, and growth charts designed for children, not adapted from adult tools - **Pediatric-specific** — prompts, calculators, milestones, and growth charts designed for children, not adapted from adult tools
- **Self-hosted** — runs on your own infrastructure, not a SaaS that holds your data - **Self-hosted** — runs on your own infrastructure, not a SaaS that holds your data
- **Provider-agnostic** — works with any AI provider (swap between them without changing anything) - **Provider-flexible** — routes through OpenRouter, Bedrock, Azure, Vertex, or LiteLLM depending on deployment configuration
- **Privacy-first** — optional fully offline transcription, auto-expiring data, no permanent PHI storage - **Privacy-conscious** — self-hosted app, encrypted sensitive fields, auto-expiring encounter/audio recovery data, and configurable BAA-eligible providers
- **Learning system** — AI improves its output based on each physician's editing patterns - **Template-aware** — user templates and prompt preferences can shape output without relying on automatic correction learning
- **All-in-one** — documentation, calculators, education, and administration in a single platform - **All-in-one** — documentation, calculators, education, and administration in a single platform

View file

@ -3,7 +3,7 @@
> Deep, dev-friendly documentation of how each part of the ped-ai app > Deep, dev-friendly documentation of how each part of the ped-ai app
> actually works. Written so a human developer can understand the > actually works. Written so a human developer can understand the
> codebase without spelunking, and so an AI assistant can confidently > codebase without spelunking, and so an AI assistant can confidently
> modify code without breaking sacred zones. > modify code without breaking high-risk workflows.
These docs explain **application logic** — what the user does, what the These docs explain **application logic** — what the user does, what the
system does in response, what the data flow is, and **why** the design system does in response, what the data flow is, and **why** the design
@ -16,9 +16,9 @@ recipes (see [`../deployment.md`](../deployment.md)).
For someone brand new to the codebase: For someone brand new to the codebase:
1. **[architecture.md](architecture.md)** — Start here. The big picture: 1. **[architecture.md](architecture.md)** — Start here. The big picture:
IIFE frontend pattern, lazy tab loading, backend route convention, current frontend pattern, lazy tab loading, backend route convention,
PostgreSQL schema, encryption at rest, Dockerfile + compose layout, PostgreSQL schema, encryption at rest, Dockerfile + compose layout,
sacred zones. (~2,000 lines, the longest doc — but the foundation.) and high-risk zones.
2. **[clinical-notes.md](clinical-notes.md)** — How every clinical note 2. **[clinical-notes.md](clinical-notes.md)** — How every clinical note
tab works. The shared "record → transcribe → generate → save" tab works. The shared "record → transcribe → generate → save"
@ -33,17 +33,15 @@ For someone brand new to the codebase:
composed in this codebase. Read this for a worked example. composed in this codebase. Read this for a worked example.
4. **[bedside-and-calculators.md](bedside-and-calculators.md)** — 4. **[bedside-and-calculators.md](bedside-and-calculators.md)** —
Bedside emergencies module (the one ES-module pocket of the Bedside emergencies module, the pediatric calculators (BP percentile, Fenton growth,
frontend), the pediatric calculators (BP percentile, Fenton growth,
bilirubin nomograms, etc.), the PE Guide, vax schedule, milestones. bilirubin nomograms, etc.), the PE Guide, vax schedule, milestones.
Includes the suture selector. **Important:** lists every clinical Includes the suture selector. **Important:** lists every clinical
formula that must NOT be modified without test vectors. formula that must NOT be modified without test vectors.
5. **[ai-and-voice.md](ai-and-voice.md)** — The 5-provider AI routing 5. **[ai-and-voice.md](ai-and-voice.md)** — AI provider routing
(`callAI`), the centralized `PROMPTS` object with DB overrides, the (`callAI`), the centralized `PROMPTS` object with DB overrides, the
`wrapUserText` + `INJECTION_GUARD` safety pattern, server-side STT `wrapUserText` + `INJECTION_GUARD` safety pattern, server-side STT
routing (Whisper / AWS Transcribe / Vertex / LiteLLM), browser routing, TTS, and the AudioRecorder. Voice/STT plumbing is high-risk — the
Whisper, the AudioRecorder. Voice/STT plumbing is **sacred** — the
doc describes it without proposing changes. doc describes it without proposing changes.
6. **[auth-admin-learning.md](auth-admin-learning.md)** — Authentication 6. **[auth-admin-learning.md](auth-admin-learning.md)** — Authentication
@ -72,43 +70,34 @@ Each doc follows the same structure:
- **Data flow** — what HTTP calls happen, what the server does - **Data flow** — what HTTP calls happen, what the server does
- **File map** — which files do what - **File map** — which files do what
- **Key design decisions***why* it works the way it does - **Key design decisions***why* it works the way it does
- **Sacred zones** — what NOT to refactor without explicit approval - **High-risk zones** — what requires small, tested changes
- **How to extend** — concrete recipes for adding a new X - **How to extend** — concrete recipes for adding a new X
When a doc mentions a sacred zone, it means there's a project-memory When a doc mentions a high-risk zone, changes should be small, well-tested, and
rule that this code must not be refactored without per-change approval directly tied to the requested behavior. Current high-risk areas:
from Daniel. The full sacred-zone roster:
| Zone | Why | | Zone | Why |
|---|---| |---|---|
| `public/js/encounters.js` save/load/idempotency | Save/version/idempotency logic has been carefully tuned; refactors keep silently breaking it. | | `public/js/encounters.js` save/load/idempotency | Save/version/idempotency logic has been carefully tuned; refactors keep silently breaking it. |
| Voice/STT plumbing (`audioBackup.js`, `speechRecognition.js`, `browserWhisper.js`, `voicePreferences.js`, `transcriptionSettings.js`, recorder paths in each clinical tab) | Recording UX has been hardened against many edge cases; refactor only with smallest-diff bug fixes. | | Voice/STT plumbing (`audioBackup.js`, `speechRecognition.js`, `voicePreferences.js`, `transcriptionSettings.js`, recorder paths in each clinical tab) | Recording UX has been hardened against many edge cases; refactor only with smallest-diff bug fixes. |
| Validated clinical formulas (BP percentile LMS, Fenton 2013, bilirubin AAP 2022, Bhutani, APLS / Best-Guess weight, PE Guide SCALES) | Validated against peditools / AAP tables; modifying without test vectors risks miscoding patient care. | | Validated clinical formulas (BP percentile LMS, Fenton 2013, bilirubin AAP 2022, Bhutani, APLS / Best-Guess weight, PE Guide SCALES) | Validated against peditools / AAP tables; modifying without test vectors risks miscoding patient care. |
| Auth + crypto (`crypto.js`, `passwords.js`, `sessions.js`, `auth.js`, `oidc.js`) | Security; changes without security review are unsafe. | | Auth + crypto (`crypto.js`, `passwords.js`, `sessions.js`, `auth.js`, `oidc.js`) | Security; changes without security review are unsafe. |
| MDM rubric in `PROMPTS.edFinalize` | Load-bearing for billing accuracy; trim only with explicit AMA/coding source citation. | | MDM rubric in `PROMPTS.edFinalize` | Load-bearing for billing accuracy; trim only with explicit AMA/coding source citation. |
## Total size
~8,300 lines of new application-logic documentation across 6 files. If
that feels like a lot, remember: the codebase is ~33,000 lines of
frontend JS + ~14,000 lines of backend JS. The docs are dense by design
— "200% detailed" was the explicit ask. Search them like a reference;
don't try to read end to end.
## Cross-cutting topics ## Cross-cutting topics
A few topics span multiple docs. Use these as your jump-off points: A few topics span multiple docs. Use these as your jump-off points:
| Topic | Where to look | | Topic | Where to look |
|---|---| |---|---|
| The IIFE pattern + `window.x = y` cross-file globals | architecture.md §2-3 | | Frontend globals, ES modules, and lazy tab loading | architecture.md |
| Lazy tab loading (`loadComponent`, `tabChanged` event) | architecture.md §3-4 | | Lazy tab loading (`loadComponent`, `tabChanged` event) | architecture.md |
| `getUserMemoryContext` → templates feeding into AI prompts | clinical-notes.md §6, ed-encounters.md §9 | | `getUserMemoryContext` → templates feeding into AI prompts | clinical-notes.md §6, ed-encounters.md §9 |
| The helper trio: `refineDocument`, `suggestBillingCodes`, `suggestDontMiss` | ai-and-voice.md §12, clinical-notes.md §5 | | The helper trio: `refineDocument`, `suggestBillingCodes`, `suggestDontMiss` | ai-and-voice.md §12, clinical-notes.md §5 |
| `wrapUserText` + `INJECTION_GUARD` prompt-injection defense | ai-and-voice.md §5 | | `wrapUserText` + `INJECTION_GUARD` prompt-injection defense | ai-and-voice.md §5 |
| `saveEncounter` API + optimistic locking + idempotency keys | architecture.md §13, clinical-notes.md §4, ed-encounters.md §5 | | `saveEncounter` API + optimistic locking + idempotency keys | architecture.md §13, clinical-notes.md §4, ed-encounters.md §5 |
| `cryptoUtil.encryptString` / `encryptBuffer` "enc1:" format | architecture.md §12 | | `cryptoUtil.encryptString` / `encryptBuffer` "enc1:" format | architecture.md §12 |
| 5-provider AI routing (`callAI`) | ai-and-voice.md §2-3 | | AI provider routing (`callAI`) | ai-and-voice.md §2-3 |
| 2023 AMA E/M MDM rubric | ed-encounters.md §6 | | 2023 AMA E/M MDM rubric | ed-encounters.md §6 |
| User templates (`user_memories` table, `template_*` categories) | clinical-notes.md §6, ed-encounters.md §9 | | User templates (`user_memories` table, `template_*` categories) | clinical-notes.md §6, ed-encounters.md §9 |

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,821 +1,45 @@
# ED Encounters — Application Logic # ED Encounters
> Multi-stage emergency department documentation with per-stage AI generation, The ED encounter workflow is a multi-stage clinical documentation flow for
> "don't miss" tooltips per stage, and a final consolidate→MDM pipeline at emergency visits.
> Save & Done. Lives in its own tab between **Dictation HPI** and the
> **Notes** sidebar group.
This is the deepest, freshest doc in the `logic/` series — the feature was ## Shape
built and revised across one focused session in late April 2026 and most of
the architectural decisions are explicitly motivated below. Read this first if
you want to understand how a clinical workflow gets composed in this codebase.
--- - Frontend: `public/js/ed-encounters.js`.
- Backend: `src/routes/edEncounters.js`.
- Prompts: ED-specific entries in `src/utils/prompts.js`.
- Helpers: billing suggestions and don't-miss review can run after generated
ED output.
## 1. What this is ## Typical Flow
An ED encounter is structurally different from every other clinical note in 1. Capture initial ED context and generate an initial note/stage output.
the app: 2. Add interval updates as the encounter evolves.
3. Consolidate relevant stages into the final ED note.
4. Generate MDM/final documentation using the ED finalize prompt.
5. Optionally run billing and don't-miss helpers.
6. Save or reload the encounter through the shared encounter system.
- A sick visit, well visit, SOAP note, or HPI is **one transcript → one ## Design Constraints
generated note**. The physician records, clicks Generate, edits, saves.
- An ED encounter is **multiple successive recordings → multiple successive
notes → one final consolidated note + MDM**. The physician dictates the
initial assessment, generates a note. Labs come back, they record more,
generate again. After consult, more dictation, generate again. When done
(could be after 1, 2, 3, or N stages), they click Save & Done and the
server consolidates everything into one polished note plus a 2023 AMA E/M
Medical Decision-Making block for billing.
The user-visible model: each generated stage stays on screen as its own - Later stages should not silently overwrite earlier clinical text.
editable card with its own "Don't Miss" panel. The physician can edit any - Regeneration should make it clear which stage is being updated.
stage at any time. Whatever's on screen at finalize time is what gets sent - MDM/finalization prompt changes should be conservative and coding-aware.
to the consolidate step. - Don't-miss output is clinician-facing safety support, not a replacement for
clinical judgment.
Physicians can also include direct asides in their dictation ## User Templates
("include normal cardiac exam", "assessment is viral URI") and the AI is
explicitly instructed to route those to the right note section instead of
quoting them.
--- Templates saved under ED-relevant categories can be included through
`/api/memories/context` and passed as `physicianMemories`. Legacy
`correction_*` rows are filtered out.
## 2. The user flow, end to end ## Testing Checklist
1. **Open the tab.** Sidebar → **ED Encounter**. Tab is lazy-loaded (the When changing ED behavior:
`<section id="ed-tab" data-component="ed-encounter">` placeholder in
`public/index.html` triggers a fetch of `public/components/ed-encounter.html`
on first activation).
2. **Enter patient info.** Label (required for save), age, gender, chief
complaint (required for generation), and a model dropdown (the same
`class="tab-model-select"` pattern every clinical tab uses; auto-populated
by `app.js` against the admin's allowed-models list).
3. **Record or type Stage 1 dictation.** Standard recorder (`AudioRecorder` from
`public/js/audioBackup.js`) + browser SpeechRecognition for live transcript
preview + final server STT pass on stop. Same plumbing as every other
clinical tab.
4. **Click "Generate Stage 1 Note".** Frontend POSTs to
`/api/ed-encounters/generate` with `{stage: 1, transcript, chiefComplaint,
patientAge, patientGender, physicianMemories, model}`. Server returns
`{success, note, dontMiss[], model}`. Note appears as **Stage 1** card.
Don't-miss items appear as a yellow/orange section embedded in that same
card.
5. **Edit if needed.** Each stage's note element is `contenteditable`. Type
freely; edits persist to localStorage on each input event.
6. **Refine the latest stage** (optional). The "Refine latest" textarea +
button at the bottom of the stage list calls `/api/refine` with the
latest stage's text + your instruction. The latest stage's text is
replaced inline with the refined version.
7. **Add another stage** (optional). Click "Add more (next stage)". Stage 1
card stays visible. Transcript box clears. Badge changes to "Stage 2
(recording)" — yellow background — meaning we've advanced but no Stage 2
note has been generated yet.
8. **Repeat** for as many stages as you need.
9. **Click "Save & Done (with MDM)"** at any point.
- Server runs `edConsolidate` → produces one polished final note that
integrates every stage chronologically.
- Server then runs `edFinalize` → produces a 2023 E/M MDM block as JSON.
- Both come back in one HTTP response.
- Frontend renders a **"Final Consolidated Note"** card (blue left border)
and a **"Medical Decision Making (2023 E/M)"** card (green left border)
below the stage cards.
- Stage cards become read-only.
- The whole thing persists to `saved_encounters` with `status='final'`.
- Local draft cleared.
--- 1. Run syntax checks for `public/js/ed-encounters.js` and
`src/routes/edEncounters.js`.
## 3. State model 2. Run `npm test`.
3. Manually test stage generation, finalization, save/load, and helper panels
Lives in a closure variable in `public/js/ed-encounters.js`: in an authenticated session when possible.
```js
_state = {
stage: 1, // current stage number (what the recorder is for)
stages: [ // per-stage history — one entry per generated stage
{ transcript, note, dontMiss[], model, generatedAt }
],
finalized: false,
finalNote: null, // consolidated final note from /finalize
mdm: null // 2023 E/M MDM block from /finalize
}
```
### Invariants
- `_state.stage` increments monotonically. It only goes up via the user
clicking "Add more". It is the **target** stage of the recorder/generate
button.
- `_state.stages.length` is the number of stages **already generated**. The
array is indexed 0..N-1; stage 1 is at index 0, stage 2 at index 1, etc.
- The relationship `_state.stages.length === _state.stage` means "the
current stage has been generated, ready to finalize or advance."
- The relationship `_state.stages.length === _state.stage - 1` means
"we're recording into a new stage that hasn't been generated yet" (the
yellow `Stage N (recording)` badge state).
- `_state.finalized` flips to `true` only when finalize succeeds. Once true,
Add more / Generate / Refine all reject with a toast.
### Why this shape
Earlier iterations stored a single `currentNote` string and rotated stages
out of view on each generation. Daniel's clarification was explicit: every
stage's note must remain visible and editable; the final MDM should reflect
whatever's on screen, including any inline physician edits to earlier stages.
The `stages[]` array is the source of truth and `gatherCurrentNotes()`
re-syncs it from the DOM before any operation that needs current text.
---
## 4. The badge — accurate state communication
Top-right of the save bar. Exactly four states:
| Condition | Text | Background |
|---|---|---|
| `_state.stages.length >= _state.stage` (current stage has been generated) | `Stage N` | Gray |
| `_state.stages.length < _state.stage` (advanced past last generation; no note for stage N yet) | `Stage N (recording)` | Yellow |
| `_state.finalized && !_state.mdm` (transient) | (not reached — finalize is atomic) | — |
| `_state.finalized` | `Finalized` | Green |
This badge was the source of the most confusing UX bug in v1: clicking
"Add more" used to immediately flip the badge to "Stage 2" even though
no Stage 2 note existed yet. The fix isn't subtle — `updateBadge()` derives
the label from the relationship between `_state.stages.length` and
`_state.stage`, with explicit color coding so the difference is
unmistakable.
---
## 5. Frontend file map
### `public/components/ed-encounter.html`
The static markup. Roughly:
- **Save bar** (`#ed-save-bar`) — label input, badge (`#ed-stage-badge`),
Save draft / Load / New buttons, plus a Load popover for saved drafts.
- **Patient Info card** — age (`#ed-age`), gender (`#ed-gender`), chief
complaint (`#ed-cc`), and the model select (`#ed-model-select` with the
`tab-model-select` class).
- **Recording card** — header showing `Stage <span id="ed-rec-stage-num">N</span>
Recording / Dictation`, the Listen In / Pause buttons, the recording
indicator with timer, and a contenteditable transcript box (`#ed-transcript`).
- **Generate button** (`#btn-ed-generate`) — `Generate Stage <span id="ed-gen-stage-num">N</span> Note`.
- **`#ed-stages-container`** — empty div. JS appends one card per stage here.
- **`#ed-tail-controls`** — refine bar (textarea + Refine latest + Shorter)
+ stage-control row (Add more, Save & Done). Hidden until at least one
stage exists. Hidden again after finalize (encounter is locked).
- **`#ed-mdm-card`** — the green-bordered MDM card. Hidden until finalize.
The blue-bordered "Final Consolidated Note" card is **created
dynamically** by `renderFinalNote()` and inserted before the MDM card.
### `public/js/ed-encounters.js`
Single IIFE module. Key functions:
| Function | What it does |
|---|---|
| `freshState()` | Returns a clean `_state` object — used at module load and `resetEncounter()` |
| `gatherCurrentNotes()` | Walks the DOM stage cards (`#ed-stage-text-N` elements) and writes their current text back into `_state.stages[N].note`. Called before persist, advance, finalize, generate (the last because the AI prompt for stage N+1 needs the latest text of stage N as `previousNote`). |
| `persistLocal()` | Debounced 300ms localStorage save under key `ped_ed_draft_v1`. Snapshot includes `_state` plus the current label/age/gender/CC/transcript box content. |
| `loadLocal()` | Reverse of `persistLocal()` — restores state on tab open if a draft exists. |
| `renderStages()` | Rebuilds `#ed-stages-container` from `_state.stages[]`. Each card gets a unique id `ed-stage-text-N`. Cards become `contenteditable=false` after finalize. |
| `buildStageCard(idx, stage)` | Constructs one card's DOM. Includes the editable note + an embedded yellow "Don't Miss — Stage N" section if `stage.dontMiss` is non-empty. |
| `renderFinalNote(note)` | Lazily creates `#ed-final-note-card` (blue border) and inserts it before the MDM card. |
| `renderMdm(mdm)` | Fills `#ed-mdm-card` with structured MDM HTML (problems / data / risk paragraphs + suggested level + rationale + disclaimer). |
| `updateBadge()` | Sets `#ed-stage-badge` text + background color based on state. |
| `initRecording()` | Wires the record / pause buttons, browser SpeechRecognition, AudioRecorder, transcribe-on-stop. Identical pattern to `sickVisit.js` — copy-pasted because the recording paths are sacred and shouldn't be factored into a shared helper. |
| `generateStage()` | Validates inputs, calls `gatherCurrentNotes()`, fetches user templates via `getUserMemoryContext()`, POSTs `/api/ed-encounters/generate`, pushes the result into `_state.stages[stage-1]`, re-renders, autoSaves a draft to the DB. |
| `advanceStage()` | Validates current stage exists, gathers edits, increments `_state.stage`, clears the transcript box, updates the badge to "(recording)", scrolls to the recorder. **Does NOT touch any displayed cards.** |
| `finalize()` | Validates label + at least one stage, gathers edits, POSTs `/api/ed-encounters/finalize` with the full stages array, on success renders Final Note + MDM cards, marks finalized, calls `saveEncounter` with `status='final'`, clears localStorage. |
| `composeFinalNoteForSave(note, mdm)` | Concatenates the final note + MDM block into a single text blob written to `saved_encounters.generated_note` (so the saved encounter has a single coherent stored note for any downstream consumer). |
| `autoSaveDraft()` | Best-effort — saves a `status='draft'` row to `saved_encounters` if a label is set. Called after each stage generation. Silent if no label. |
| `resetEncounter()` | "New" button — wipes state, removes stage cards, hides Final Note + MDM, clears localStorage, drops the saved-encounter id. |
| `refineLatestStage()` / `shortenLatestStage()` | Resolve the latest stage's text element id (`stageTextElId(stages.length - 1)`) and call the global `refineDocument` / `shortenDocument` helpers from `app.js`. |
### How the file integrates with `encounters.js`
`public/js/encounters.js` is **sacred** (per the project memory file —
don't refactor without per-change approval, especially the save/idempotency
logic). ED encounters needed exactly two minimal touches to it:
1. Line 98: `'ed'` added to the sessionStorage restore array so the
`_savedEncId_ed` value survives page refresh.
2. Lines 305-310: `ed: 'ed'` added to the `tabMap` in `resumeEncounter` so
the saved-encounters list can navigate to the ED tab when a user
clicks a saved ED row.
That's it. Save logic, idempotency, optimistic versioning — all reused
unchanged via `window.saveEncounter()`. ED rows store with `enc_type='ed'`
and `partial_data` containing the full `{stages, finalNote, mdm, finalized}`
JSON for resume.
A `registerEncounterLoadHandler('ed', fn)` call near the bottom of
`ed-encounters.js` registers the resume handler with `encounters.js`. When
a user clicks an ED row in the Load popover, `encounters.js` invokes that
handler with the decrypted row, and the handler restores `_state` and
re-renders.
---
## 6. Backend file map
### `src/routes/edEncounters.js`
Two endpoints, both auth-gated.
#### `POST /api/ed-encounters/generate`
Per-stage note generation. Body:
| Field | Type | Notes |
|---|---|---|
| `stage` | number | Informational; the prompt is told this is "Stage N" |
| `transcript` | string | **Required.** This stage's raw dictation. |
| `chiefComplaint` | string | **Required.** Same as in other tabs. |
| `patientAge` | string | Optional but strongly preferred — drives "don't miss" tailoring |
| `patientGender` | string | Optional |
| `previousNote` | string | Stage 2+ only. The previous stage's current text (after edits). |
| `physicianMemories` | string | Concatenated user templates from `/api/memories/context` |
| `model` | string | Optional override. Validated by callAI's allowlist. |
Returns:
```json
{ "success": true, "note": "<plain-text note>", "dontMiss": [{"point","why"}], "model": "<id>" }
```
The route assembles a structured user message:
```
ED ENCOUNTER — STAGE N
Patient: <age>, <gender>
Chief Complaint: <wrapped>
CURRENT STAGE TRANSCRIPT (may include direct physician asides — preserve and route them per the prompt rules):
<wrapped transcript>
PREVIOUS-STAGE NOTE (baseline to integrate on top of — do not start fresh):
<wrapped previous note> [only stage 2+]
PHYSICIAN TEMPLATES AND PREFERENCES: [if any]
<wrapped templates>
```
Calls `callAI` with `PROMPTS.edEncounterStaged + INJECTION_GUARD` as system
and the assembled user message. Parses the response with `extractJson`.
**Recovery logic:** if the model returns plain prose instead of JSON
(model occasionally shortcuts), the route treats the entire response as
the note and returns an empty don't-miss list rather than 500-ing. The
physician still gets a usable note.
`dontMiss` is filtered to entries with non-empty `point` and trimmed.
Audit + apiCall logs are written.
#### `POST /api/ed-encounters/finalize`
Two-call server-side pipeline. Body:
| Field | Type | Notes |
|---|---|---|
| `stages` | `[{transcript, note}]` | **Required.** Array in chronological order. Empty stages are filtered. |
| `chiefComplaint` | string | Optional but strongly preferred |
| `patientAge` | string | Optional |
| `patientGender` | string | Optional |
| `model` | string | Optional override |
The route:
1. **Step 1 — consolidate.** Builds a context with chief complaint, demographics,
and a labeled `=== STAGE N ===` block for each stage (transcript +
working note). System prompt is `PROMPTS.edConsolidate`. Returns the
model's plain-text response as `finalNote`.
2. **Step 2 — MDM.** Builds a context with the consolidated `finalNote` plus
the full transcript across all stages. System prompt is
`PROMPTS.edFinalize`. Parses JSON for `{mdm: {...}}`.
Returns:
```json
{
"success": true,
"finalNote": "<plain-text consolidated note>",
"mdm": {
"problemsAddressed": "minimal|low|moderate|high",
"problemsNarrative": "...",
"dataReviewed": "minimal|limited|moderate|extensive",
"dataNarrative": "...",
"risk": "minimal|low|moderate|high",
"riskNarrative": "...",
"suggestedLevel": "99281|99282|99283|99284|99285",
"levelRationale": "..."
},
"model": "<id>"
}
```
**Why two calls instead of one combined prompt:** each task has a
focused rubric (the consolidate prompt enforces section structure and
chronological integration; the MDM prompt enforces the 2023 AMA element
definitions). Asking for both in one JSON tends to make the model
shortcut one or the other. Two calls cost ~2x latency at the very end of
the encounter — acceptable since finalize is a one-time terminal action.
If the MDM step's JSON parse fails, the route returns 502 but **still
includes the finalNote** in the error payload so the client doesn't lose
work. (The client doesn't currently surface this case to the user — TODO
to render the partial result with a "MDM failed, retry" affordance.)
Token usage from both calls is summed for the apiCall log.
### `src/utils/prompts.js` — the three ED prompts
#### `edEncounterStaged`
System prompt for per-stage generation. Returns strict JSON `{note, dontMiss[]}`.
Key instructions:
- Note structure is **fixed**: Chief Complaint, HPI (OLDCARTS, historian
noted), ROS (per ROS_PE_RULES), PE (per ROS_PE_RULES), ED Course (only
when present), Assessment and Plan.
- Don't-miss list is **uncapped** for ED (unlike the global `dontMissTooltip`
prompt which hard-caps at 5 for sick visit / encounter HPI). Quality
over quantity. Tailored strictly to age + chief complaint.
- **PRESERVE INSTRUCTIONS WITHIN DICTATION** — explicit instruction that
physician asides like "include normal cardiac exam" or "assessment is
viral URI" are first-class clinical input. Route exam findings to PE,
assessment statements to A&P, plan statements to A&P. Never echo as
quoted speech.
- **Templates** — the user's templates (especially `template_ed`, but also
matching `template_hpi`/`template_soap`/`template_sickvisit`) are
delivered in the user message as PHYSICIAN TEMPLATES AND PREFERENCES.
Apply matching template sections; never copy clinical content from a
template — only formatting/structure.
- **Previous-stage note** — explicit instruction: integrate the new
transcript on top of the previous note, do not start fresh. Drop
don't-miss items that have been addressed.
The prompt is appended with `INJECTION_GUARD` from `promptSafe.js` to
defend against prompt-injection attempts inside the dictation.
#### `edConsolidate`
System prompt for the consolidate step at finalize. **Plain text output**
(no JSON wrapper).
Key instructions:
- Same fixed note structure as the staged prompt.
- **Integration rules**: use the latest stage as the structural baseline
(it already integrates earlier stages); use earlier stages and
transcripts to fill gaps. ED Course should reflect chronological
progression. Resolve contradictions by using the later value AND
noting the change in ED Course (e.g., "now afebrile after antipyretic").
- Preserve every clinical fact; never drop information; never invent.
#### `edFinalize`
System prompt for the MDM step. Returns strict JSON `{mdm: {...}}`.
Includes a **full inline rubric** of the 2023 AMA E/M MDM table so the
model has clear definitions to score against:
- **Element 1 — Problems addressed**: minimal / low / moderate / high
with explicit definitions (e.g., "high = chronic illness with severe
exacerbation, OR acute illness/injury that poses threat to life or
bodily function").
- **Element 2 — Data reviewed**: categories (tests reviewed, tests
ordered, independent interpretation, discussion with another physician,
external records, independent historian) and counting rules for
minimal / limited / moderate / extensive.
- **Element 3 — Risk**: minimal / low / moderate / high with concrete
examples per level (drug therapy requiring intensive monitoring,
decision regarding hospitalization, etc.).
- **Level mapping** (2 of 3 elements must meet the level): 99281 through
99285 with descriptions of the typical encounter at each level
(99284 = "MODERATE complexity MDM, most common ED visit with workup,
labs, or imaging and prescription decisions"; 99285 = "HIGH complexity
MDM, admission for high-acuity care").
Critical rules at the bottom:
- Use only information present in the note (and transcript if provided).
- Never invent.
- Conservative when ambiguous — pick the lower level.
- `levelRationale` must reference specific elements actually documented.
This prompt is the most important to get right — it's what determines the
suggested billing level. Daniel called this out explicitly: "make sure mdm
is configured well." The full element rubric is inline so the model isn't
relying on its training to remember the 2023 guidelines correctly.
---
## 7. Persistence — three layers
### Layer 1 — localStorage (`ped_ed_draft_v1`)
Debounced 300ms after every input event. Snapshot includes the entire
`_state` plus the current label / age / gender / CC / transcript box
content. Survives page refresh, browser restart, signing out + back in.
Cleared on `resetEncounter()` and on successful finalize.
This is the **fast** layer — captures every keystroke without round-trips.
### Layer 2 — saved_encounters DB row, status='draft'
Best-effort auto-save after each stage generation. Requires a label to be
set; silent no-op otherwise. Uses `window.saveEncounter` from
`encounters.js` with:
- `enc_type: 'ed'`
- `status: 'draft'`
- `generated_note`: the latest stage's note (so the saved-encounters list
has something to preview)
- `partial_data`: encrypted JSON containing `{stages, finalized: false}`
- `idempotency_key: 'ed-draft-' + savedId`
The same row gets updated on each subsequent generation (by passing
the saved id back to `saveEncounter`).
This is the **durable** layer — survives device loss because it's on the
server, encrypted at rest with the app key.
### Layer 3 — saved_encounters DB row, status='final'
Written exactly once on successful finalize. Includes:
- `generated_note`: the **final consolidated note + MDM block** combined
via `composeFinalNoteForSave()`, so any downstream consumer (export,
copy, print) gets a single coherent text.
- `partial_data`: `{stages, finalNote, mdm, finalized: true}` — full
fidelity for resume / audit / future re-render.
- `idempotency_key: 'ed-final-' + Date.now()` — unique per finalize
attempt so a network retry doesn't create a duplicate row.
After finalize, localStorage is cleared. The encounter is locked from
further edits in-app (stage cards become `contenteditable=false`, tail
controls hidden). The user can still load the row later for review;
editing requires loading then unlocking by some manual workflow that
doesn't yet exist (TODO if requested).
---
## 8. The "Don't Miss" tooltip — per-stage
Each stage's response from `/api/ed-encounters/generate` includes a
`dontMiss[]` array of `{point, why}` objects. Same JSON call as the
note — no second AI request. The stage card embeds these as a
yellow/orange section beneath the note text:
```
┌─────────────────────────────────────────┐
│ Stage 2 Note [model] [Copy] │
├─────────────────────────────────────────┤
│ Chief Complaint: ... │
│ HPI: ... │
│ ... │
├─────────────────────────────────────────┤ ← yellow background
│ ⚠ Don't Miss — Stage 2 │
│ • Document hydration status │
│ (tachycardia + 4-day vomiting) │
│ • Consider DKA workup │
│ (polyuria + weight loss in HPI) │
│ • ... │
└─────────────────────────────────────────┘
```
ED don't-miss is **uncapped** by design — Daniel wanted no limit ("just like
remember to ask this, do this, keep this in mind etc."). Compare with
`/api/dont-miss` (used by sick visit + encounter HPI) which caps at 5
both in the prompt and via server-side `.slice(0, 5)`.
The same-call design (note + don't-miss in one JSON) was a deliberate
choice to keep per-stage latency down. The risk (model occasionally
shortcuts the don't-miss list) is acceptable per Daniel since don't-miss
is informational, not load-bearing.
---
## 9. Templates — `template_ed`
When a physician saves a template under category `template_ed`
(Settings → Templates), it gets included in the `physicianMemories` string
that the frontend fetches via `getUserMemoryContext()` and passes to
`/api/ed-encounters/generate`.
The flow:
1. User saves a template named e.g. "ED Pearls" with category `template_ed`.
2. Server stores it in `user_memories` (encrypted name + content).
3. On next ED note generation, `ed-encounters.js` calls
`getUserMemoryContext()` (defined in `public/js/memories.js`).
4. That function fetches `/api/memories/context` which returns a single
string containing every active template (correction_* rows are
filtered out at the SQL level — the AI corrections feature was
removed in late April 2026).
5. The string is included in the user message under the
"PHYSICIAN TEMPLATES AND PREFERENCES" header.
6. The system prompt's "PHYSICIAN TEMPLATES" section instructs the model
to apply matching sections from any template (especially `template_ed`,
but also matching HPI/SOAP/sickvisit templates).
`template_ed` was added to `VALID_CATEGORIES` in `src/routes/memories.js`
and to the dropdown in `public/components/settings.html`.
---
## 10. Why the design looks like this
Each major decision, with the constraint that motivated it.
### Why per-stage cards (vs. one rolling note element)
**Daniel's clarification.** The first build used one `#ed-note-text`
element that got replaced on each generation. After demo: "every stage
note should be shown, if AI is told to modify that particular note then
the modified version is used in final mdm." The cards model is the
direct response — every stage stays visible, every stage is editable,
edits flow into finalize.
### Why the badge has an explicit "(recording)" state
**Bug from first user test.** Clicking "Add more" used to flip the badge
to "Stage 2" immediately, before any Stage 2 note existed. Daniel: "the
title changes to stage 2 even without a new recording and generate being
hit." The fix isn't subtle — `updateBadge()` derives state from the
relationship between `stages.length` and `_state.stage`, with explicit
color coding so the difference is unmistakable.
### Why finalize is two server-side AI calls instead of one
**Quality concern.** A single combined "produce finalNote AND mdm in one
JSON" prompt makes the model cut corners on one of the two tasks
(usually the MDM rubric gets compressed). Two focused calls each get
their own dedicated system prompt with no competing pressure. Cost: ~2x
latency at finalize. Justification: finalize is a one-time terminal
action, not a per-stage hot path.
### Why edConsolidate returns plain text instead of JSON
**Reliability + simplicity.** The consolidate step produces ONE thing —
a clinical note. JSON wrapping adds parse-failure surface area for zero
benefit. The text is rendered directly into a `contenteditable` element.
The MDM step does need JSON because it has structured fields the UI
displays in a table layout.
### Why the MDM prompt has the full 2023 AMA rubric inline
**Daniel's directive: "make sure mdm is configured well."** Models'
training data includes pre-2023 guidelines mixed with 2023; relying on
"you know the AMA E/M MDM table" produces drift. The prompt now
includes element-by-element definitions (problems / data / risk),
counting rules for data, and concrete examples per level. The level
rationale must reference specific findings.
### Why the recorder code is copy-pasted from sickVisit.js
**Project memory: "Voice/STT is sacred — Don't refactor recorder/transcribe
plumbing; fix only named bugs in smallest diff."** The recording paths
in every clinical tab look almost identical; refactoring to a shared
helper is a textbook clean-code move that has burned this project before
(silent breakage of recording when the abstraction ate an edge case).
The deliberate non-DRY duplication is the safer choice.
### Why finalize sends the whole stages array instead of just stage texts
The consolidate prompt benefits from seeing each stage's transcript
(physician's actual dictation) AND each stage's note (which may include
physician edits). The transcripts let the AI catch facts that didn't
make it into the working notes; the notes show physician interpretation.
Both together produce a more faithful consolidation.
### Why `previousNote` is sent during stage 2+ generation, not just the transcript
The per-stage AI is told to "integrate the new transcript on top of the
previous note as baseline, do not start fresh." Without the previous
note as input, stage 2 would have to regenerate everything from
transcripts alone (slower, less faithful to physician edits made between
stages).
---
## 11. Sacred / fragile zones
These are not refactor-without-permission lines.
### `public/js/encounters.js`
Per project memory: don't touch without per-change approval; even
pre-approved changes get rejected if they refactor save/idempotency. The
ED feature touched it in exactly two places (sessionStorage array and
tabMap) and that's it. **All ED save/load goes through `window.saveEncounter`
and `registerEncounterLoadHandler` — established interfaces. Do not
add new methods to encounters.js or modify the save body shape.**
### Recorder + transcribe paths
`public/js/audioBackup.js` (the AudioRecorder class), the
`transcribeAudio` global function, the SpeechRecognition wrapper from
`public/js/speechRecognition.js`. The ED tab's recording logic in
`initRecording()` was copied from `sickVisit.js` deliberately. Don't
factor it out into a shared `record-and-transcribe-helper.js`.
### The MDM prompt rubric
The 2023 AMA E/M element definitions and level mapping in
`PROMPTS.edFinalize` are load-bearing for billing accuracy. Don't trim
them to "save tokens" — the cost of a miscoded encounter to a real
practice is much higher than the prompt overhead. Update only with
explicit billing/coding source citation.
---
## 12. How to extend — concrete recipes
### Add a new prompt key
1. Add the entry to `PROMPTS` in `src/utils/prompts.js`. Use the same
`${CORE_RULES}` and (if relevant) `${ROS_PE_RULES}` preambles other
prompts use.
2. The DB-override system (`loadFromDb` in the same file) auto-picks up
the new key on next startup, so admins can override it from the
Admin → Prompts UI without code changes.
### Tweak the MDM rubric
Edit `PROMPTS.edFinalize` in `src/utils/prompts.js`. Cite the source
(2023 AMA E/M Office or Other Outpatient guideline updates, or AMA
errata) in the commit message. The rubric structure is stable —
changes are usually wording refinements, not category rewrites.
### Add a new field to the stage card (e.g., timestamp)
1. The data is already in `_state.stages[i].generatedAt`.
2. In `buildStageCard(idx, stage)` in `public/js/ed-encounters.js`,
add a small `<div>` next to the model tag in the card header.
3. No backend change needed; `generatedAt` is already saved in
`partial_data`.
### Add per-stage refine (instead of "refine latest")
1. In `buildStageCard()`, render a refine input + button for every
stage card.
2. Update the refine button click handler to read `data-stage-idx` from
the clicked button and pass `stageTextElId(idx)` to `refineDocument`.
3. Be aware: physicians editing earlier stages then refining them then
regenerating later stages creates a complex causality chain. Daniel's
current call is "refine targets the latest stage" to avoid this.
### Add a new ED-specific output (e.g., a discharge instructions card)
1. Decide if it's per-stage or once-per-encounter. Per-encounter is
simpler — generate it on finalize.
2. Add a third server-side AI call in `/api/ed-encounters/finalize`
between consolidate and MDM. Add the result to the response payload.
3. Add a new card to `ed-encounter.html` (or create dynamically like
`renderFinalNote`).
4. Render it in the finalize success handler.
### Unlock a finalized encounter for editing (TODO — not implemented)
Currently no UI for this. Would require:
1. A new endpoint `POST /api/ed-encounters/:id/unlock` that flips
`status` from `'final'` back to `'draft'` and clears `partial_data.finalized`.
2. An "Unlock for editing" button on the load popover for finalized
rows.
3. UI logic in `ed-encounters.js` to handle the unlocked state
(re-enable editing on stage cards, re-show tail controls).
### Add a fourth stage type (currently the prompt is generic)
The current design treats all stages identically — same prompt, same
structure. If there's a value in distinguishing "initial assessment"
vs "post-workup" vs "post-consult" stages with different prompts, that's
a meaningful shift. Probable plan:
1. Add a `stageType` field to each stage entry.
2. Branch on `stageType` in the route to pick a prompt variant.
3. UI: dropdown next to the recorder that defaults to "Initial /
Workup / Consult / Disposition" based on stage number.
This isn't currently planned — the generic stage works because the
physician's dictation is what differentiates stages, not a metadata tag.
---
## 13. Testing pointers
There are currently **no Playwright e2e tests** for ED encounters — flagged
as TODO in the session that built the feature. A reasonable first batch:
1. **Stage 1 happy path.** Open tab, fill label/age/gender/CC, type
transcript, click Generate, assert Stage 1 card appears with note
text and don't-miss section.
2. **Multi-stage flow.** Stage 1 → Add more → badge says "Stage 2
(recording)" with yellow background → type Stage 2 transcript →
Generate → both Stage 1 and Stage 2 cards visible.
3. **Edit-then-finalize.** Generate Stage 1 → edit the note text inline
→ Save & Done → assert the consolidate step received the edited text
(mock `/api/ed-encounters/finalize`, inspect the request body).
4. **Finalize renders both cards.** Mock `/finalize` to return
`{finalNote, mdm}` → assert blue Final Note card AND green MDM card
appear → assert stage cards become read-only.
5. **Resume from saved.** Save a draft, reload, click Load, pick the
ED row → all stages reappear with their don't-miss sections.
The mocking pattern is in `e2e/fixtures.js``mockAI(page, overrides)`.
Add `'**/api/ed-encounters/generate'` and `'**/api/ed-encounters/finalize'`
to the routes table with canned responses.
---
## 14. Known issues / TODOs
- No e2e coverage (above).
- No "unlock" UI for finalized encounters.
- The MDM-step partial-success case (consolidate succeeded, MDM failed)
returns 502 with `finalNote` in the error payload, but the client
doesn't render the partial result. Currently the user sees a generic
error toast and loses the consolidate work.
- Per-stage refine isn't supported — only "refine latest." If the user
wants to refine an earlier stage, they edit it inline (works) but
don't get an AI-assisted refine for that specific stage.
- The "(recording)" badge color (yellow) might be confusing in the
dark theme if one is added — currently the app is light-only.
- The consolidate step uses the configured default model unless the
user picks a specific one in the dropdown. There's no way to use one
model for per-stage generation and a different model for finalize.
Probably fine; flag if a user wants this.
- `extractJson` is defined locally in `src/routes/edEncounters.js` and
duplicated from `notes.js`. Candidate for `src/utils/jsonRecover.js`
if a third route ever needs it.
---
## 15. Quick reference — the ED encounter at a glance
```
┌───────────────────────────────────────────────────────────────────┐
│ ED ENCOUNTER TAB │
│ [Patient label] [Stage N badge] │
│ Age | Gender | Chief Complaint | Model dropdown │
├───────────────────────────────────────────────────────────────────┤
│ Stage N Recording — [Listen In] [Pause] [00:23 indicator] │
│ [contenteditable transcript box] │
├───────────────────────────────────────────────────────────────────┤
│ [✨ Generate Stage N Note] │
├───────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────┐ │
│ │ Stage 1 Note [model] [Copy] │ │
│ │ [editable note text] │ │
│ │ ──────────────────────────────────────────────── │ │
│ │ ⚠ Don't Miss — Stage 1 │ │
│ │ • point 1 │ │
│ │ • point 2 │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Stage 2 Note [model] [Copy] │ │
│ │ ... │ │
│ └─────────────────────────────────────────────────┘ │
├───────────────────────────────────────────────────────────────────┤
│ [Refine input] [Refine latest] [Shorter] │
│ [+ Add more (next stage)] [✓ Save & Done (with MDM)] │
├───────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────┐ (after │
│ │ 📋 Final Consolidated Note [Copy] │ finalize) │
│ │ [polished single note from edConsolidate] │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 💵 Medical Decision Making (2023 E/M) [99284] │ │
│ │ Problems Addressed (moderate): ... │ │
│ │ Data Reviewed (moderate): ... │ │
│ │ Risk (moderate): ... │ │
│ │ Suggested Level: 99284 — rationale... │ │
│ └─────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
```
---
**Files involved** (so a reader can map this to the codebase):
| Path | Role |
|---|---|
| `public/components/ed-encounter.html` | Tab markup |
| `public/js/ed-encounters.js` | All client logic (~500 lines) |
| `public/js/encounters.js` | Sacred — saveEncounter, sessionStorage, tabMap (2-string touch only) |
| `src/routes/edEncounters.js` | `/generate` + `/finalize` endpoints |
| `src/utils/prompts.js` | `edEncounterStaged`, `edConsolidate`, `edFinalize` keys |
| `src/utils/promptSafe.js` | `wrapUserText` + `INJECTION_GUARD` |
| `src/routes/encounters.js` | Generic save infrastructure (saved_encounters table) |
| `src/routes/memories.js` | `template_ed` category in `VALID_CATEGORIES` |
| `public/js/memories.js` | `template_ed: 'ED Template'` label + `getUserMemoryContext` |
| `public/components/settings.html` | `<option value="template_ed">` in the category dropdown |
| `public/index.html` | Tab button, lazy-load section, script tag |
| `server.js` | Mounts `edEncounters` route on `/api` |

View file

@ -1,7 +1,8 @@
# Mobile build & release # Mobile Build And Release
Capacitor 6 wrapper. Android only today; iOS project exists but requires macOS Capacitor 6 wrapper around the hosted Ped-AI web app. The launcher defaults to `https://app.pedshub.com`, lets the user change the server URL, and stores that URL locally. Android is buildable on Linux. The iOS project exists but requires macOS and Xcode to produce an `.ipa`.
+ Xcode to produce an `.ipa`.
This is not a separate native clinical app. The native shell provides WebView hosting, microphone permission plumbing, secure storage, and mobile packaging for the same authenticated web app.
## One-time setup ## One-time setup
@ -25,8 +26,12 @@ npx cap open android
## CI build (preferred) ## CI build (preferred)
Tag-triggered. Push any `vX.Y.Z` tag → `.github/workflows/android-release.yml` Push-triggered. Any push to `main`/feature branches and any `vX.Y.Z` tag push
builds a signed APK on a GitHub runner and attaches it to the matching release. `.forgejo/workflows/android-apk.yml` builds a signed APK on the Forgejo
runner.
Tagged builds additionally publish the artifact to the matching Forgejo release
as `pedscribe-<tag>.apk` so Obtainium can track updates.
Required repo secrets (set once, via Settings → Secrets and variables → Actions Required repo secrets (set once, via Settings → Secrets and variables → Actions
or `gh secret set`): or `gh secret set`):
@ -35,6 +40,14 @@ or `gh secret set`):
- `ANDROID_KEYSTORE_PASSWORD` - `ANDROID_KEYSTORE_PASSWORD`
- `ANDROID_KEY_ALIAS``pedscribe` - `ANDROID_KEY_ALIAS``pedscribe`
- `ANDROID_KEY_PASSWORD` - `ANDROID_KEY_PASSWORD`
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64` — base64 of your Google Play service
account JSON (optional). If present, the same tag build also runs `bundleRelease`
and uploads the AAB to Play's `internal` track.
Optional Play Store flow:
- Service account must have permissions to edit releases on the app in Play.
- Build task is `bundleRelease`, tracked as `com.pedshub.scribe`.
- Upload lane is `fastlane/android publish_internal` (under `mobile/android/fastlane`).
Tag a release: Tag a release:
@ -44,12 +57,13 @@ git commit -m "feat: ..." && git push # auto-version workflow bumps minor
git commit -m "fix: ..." && git push # auto-version workflow bumps patch git commit -m "fix: ..." && git push # auto-version workflow bumps patch
# or force an exact version # or force an exact version
scripts/release.sh 6.2.0 --push scripts/release.sh X.Y.Z --push
``` ```
APK lands at the GitHub release; `/releases/latest` link in the login page APK lands on the Forgejo release. Obtainium can still track
resolves to it automatically. Obtanium subscribers (`github.com/<owner>/<repo>`) `git.danvics.com/danvics/pediatric-ai-scribe-v3` releases automatically.
pick up the update on next poll. Play Store upload is handled automatically for tagged builds only when
`GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64` is configured.
## Local build (fallback / debugging) ## Local build (fallback / debugging)
@ -69,6 +83,8 @@ Output: `android/app/build/outputs/apk/release/app-release.apk`
For Play Store, swap `assembleRelease``bundleRelease`; output: `.aab` under For Play Store, swap `assembleRelease``bundleRelease`; output: `.aab` under
`bundle/release/`. `bundle/release/`.
If web assets or Capacitor config changed, run `npx cap sync android` from `mobile/` before building.
### Single-quote the password ### Single-quote the password
Keystore passwords with shell metacharacters (`)`, `$`, `!`, space, etc.) must Keystore passwords with shell metacharacters (`)`, `$`, `!`, space, etc.) must
@ -109,8 +125,9 @@ user to uninstall + reinstall.
| Path | Purpose | | Path | Purpose |
|---|---| |---|---|
| `mobile/capacitor.config.json` | appId, name, WebView config, plugin opts | | `mobile/capacitor.config.json` | appId, name, WebView config, plugin opts |
| `mobile/src/` | launcher HTML (server URL entry) | | `mobile/src/` | launcher HTML and server URL entry, defaulting to `https://app.pedshub.com` |
| `mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java` | JS bridge + WebView mic permission | | `mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java` | JS bridge + WebView mic permission |
| `mobile/android/app/src/main/java/com/pedshub/scribe/AudioRecordingService.java` | foreground service for background recording | | `mobile/android/app/src/main/java/com/pedshub/scribe/AudioRecordingService.java` | foreground service for background recording |
| `mobile/android/app/src/main/AndroidManifest.xml` | permissions, intents, backup rules | | `mobile/android/app/src/main/AndroidManifest.xml` | permissions, intents, backup rules |
| `.github/workflows/android-release.yml` | CI build | | `.forgejo/workflows/android-apk.yml` | CI build |
| `mobile/android/fastlane/Fastfile` | internal Play track upload lane |

View file

@ -1,83 +1,38 @@
# Speech: STT, TTS, audio backup # Speech: STT, TTS, Audio Backup
## Transcription (speech-to-text) ## Transcription
### Overview `POST /api/transcribe` accepts `multipart/form-data` with one audio file up to 25 MB. Server STT is routed through LiteLLM.
`POST /api/transcribe` accepts `multipart/form-data` with a single audio Set `TRANSCRIBE_PROVIDER=litellm`, `LITELLM_API_BASE`, and `LITELLM_STT_MODEL`. Auto mode also uses LiteLLM when the gateway is configured.
file (≤ 25 MB). Provider is `TRANSCRIBE_PROVIDER` env var, or auto-detected
(`google > aws > openai`) from available credentials. Each user may override
via `users.stt_model`; admin-wide default via `stt.model` in `app_settings`.
### Providers | Provider | Notes | HIPAA posture |
| Provider | Transport | HIPAA (with BAA) |
|---|---|---| |---|---|---|
| **Google Gemini** | Inline audio in `generateContent` call. Default model `gemini-2.0-flash`. | Yes | | LiteLLM | Sends audio through the configured LiteLLM `/audio/transcriptions` backend. | Depends on the selected upstream. |
| **Amazon Transcribe** | Streaming. `AWS_TRANSCRIBE_MEDICAL=true` + `AWS_TRANSCRIBE_SPECIALTY` switches to Transcribe Medical. Specialties: `PRIMARYCARE`, `CARDIOLOGY`, `NEUROLOGY`, `ONCOLOGY`, `RADIOLOGY`, `UROLOGY`. | Yes |
| **Local Whisper** | Spawns `whisper.cpp` or `faster-whisper` via `WHISPER_BINARY`. Fully offline. Model sizes `tiny`/`base`/`small`/`medium`/`large`. | N/A (nothing leaves host) |
| **OpenAI Whisper** | `whisper-1` via `/v1/audio/transcriptions`. Medical-context prompt prepended: `"Medical patient encounter. Pediatric."` | No |
| **LiteLLM** | Inline audio via LiteLLM's `chat.completions` endpoint (not the `/audio/transcriptions` path). Model from `LITELLM_STT_MODEL`. | Depends on LiteLLM backend |
## Browser Whisper (fully offline) Browser Whisper and browser-local Whisper workers are not part of the runtime. Do not add browser model downloads or Transformers.js STT back into the public app.
Runs entirely in the browser via WebAssembly. Zero network. Suitable when ## Web Speech Preview
no external transcription is acceptable.
- Runtime: `@xenova/transformers` (WASM). Browser-native Web Speech can show interim text when the user explicitly enables it. It is browser/vendor dependent, may send audio to browser-provider cloud services, and should not be treated as the final clinical transcript.
- Models (bundled in the Docker image, no CDN fetch):
- `whisper-tiny.en` — 39 MB
- `whisper-base.en` — 74 MB
- `whisper-small.en` — 244 MB
- Executes in a dedicated Web Worker; UI thread is never blocked.
- Models cached in IndexedDB after first load.
- Per-user toggle. On browser transcription failure, the client falls back to
server-side transcription without user intervention.
## Live speech preview ## Text To Speech
Chrome / Edge `webkitSpeechRecognition` streams interim text to the UI during `POST /api/text-to-speech` returns audio from LiteLLM `/audio/speech`. The `X-TTS-Provider` response header identifies the LiteLLM model used. Requests are limited to 5000 characters.
recording. Used for real-time preview only — **not** for final transcription.
The actual transcript comes from the configured STT provider after recording
ends.
## Text-to-speech
### Overview
`POST /api/text-to-speech`. Returns `audio/mpeg`. `X-TTS-Provider` response
header identifies the provider used. 5000-character limit per request. Each
user may override via `users.tts_voice`; admin-wide default via `tts.voice`.
### Providers
| Provider | Notes | | Provider | Notes |
|---|---| |---|---|
| **Google Cloud TTS** | `@google-cloud/text-to-speech`. Voice families: Journey, Studio, Neural2. | | LiteLLM | Uses `LITELLM_TTS_MODEL` and `LITELLM_TTS_VOICE`. |
| **LiteLLM** | Configured via `LITELLM_TTS_MODEL` + `LITELLM_TTS_VOICE`. Backend-agnostic. |
| **ElevenLabs** | `eleven_turbo_v2_5`. **Not HIPAA-compliant**. |
## Audio backup The admin/user voice pickers read available LiteLLM-compatible voices from `LITELLM_TTS_VOICES`.
Raw audio is saved to Postgres **only when transcription fails**, providing a ## Audio Backup
retry window without persisting every recording.
### Storage Failed transcription submissions can be stored for retry instead of being silently lost.
- Gzip-compressed, then AES-256-GCM encrypted (0x01 version byte prefix). - Audio backups are compressed and encrypted before storage.
- `BYTEA` column in `audio_backups`. - Backups expire automatically.
- 24-hour `expires_at`, swept hourly. - The Settings audio backup UI can retry or delete saved items.
- Legacy rows (gzip magic `0x1F` as first byte, no encryption envelope) - Browser fallback storage is used only when the server cannot save the failed audio.
decompress as-is — detection is deterministic because `0x1F ≠ 0x01`.
### Retry UI Treat audio backups as sensitive clinical data even when encrypted.
Settings → Audio Backups:
- List: module, size, created, expiry.
- **Retry** — resubmits to `POST /api/transcribe`.
- **Delete** — purge now.
### Browser fallback
If the server-side save fails (network, 500, etc.), the client stores the audio
in IndexedDB so it can retry later. Cleared after successful submission.

View file

@ -1,279 +1,40 @@
# Transcription Options Guide # Transcription Options
## Overview Ped-AI currently supports server-side transcription through LiteLLM plus an explicit browser Web Speech preview option. Browser Whisper was removed and should not be offered in settings, documentation, public workers, or model download scripts.
Pediatric AI Scribe v2+ offers **three transcription methods**, allowing you to choose between **privacy**, **speed**, and **real-time feedback**. ## Recommended Clinical Setup
--- Route STT through LiteLLM and configure the compliant upstream in LiteLLM.
## 📊 Comparison Table | Need | Recommended provider |
|---|---|
| Server STT | LiteLLM with a compliant upstream. |
| Real-time draft preview | Browser Web Speech only with explicit user opt-in and privacy warning. |
| Feature | Browser Whisper | Server Transcription | Web Speech API | Auto-detect uses LiteLLM when `LITELLM_API_BASE` is configured. Direct Google, AWS, local Whisper, and OpenAI Whisper branches are not part of the app runtime.
|---------|----------------|---------------------|----------------|
| **Privacy** | ⭐⭐⭐⭐⭐ 100% offline | ⭐⭐⭐⭐ (with BAA) | ⭐ Sends to cloud |
| **Accuracy** | ⭐⭐⭐⭐⭐ Whisper | ⭐⭐⭐⭐⭐ Gemini/AWS | ⭐⭐⭐ Browser-dependent |
| **Speed** | ⭐⭐⭐ 2-10s | ⭐⭐⭐⭐⭐ ~1s | ⭐⭐⭐⭐⭐ Instant |
| **Real-time** | ❌ Batch mode | ❌ Batch mode | ✅ Live streaming |
| **HIPAA** | ✅ Yes | ✅ (Vertex/AWS) | ❌ No |
| **Cost** | Free | ~$0.005/min | Free |
| **Internet** | ❌ Not required | ✅ Required | ✅ Required |
| **Setup** | None (bundled) | API keys | None (built-in) |
---
## Option 1: Browser Whisper (Offline, Private) ⭐ RECOMMENDED
### What It Is
- Runs **OpenAI Whisper** entirely in your browser using WebAssembly
- Audio **never leaves your device** - 100% offline after initial page load
- Models bundled in Docker image (self-hosted, no CDN)
### When to Use
- ✅ Clinical documentation (HIPAA-compliant)
- ✅ Maximum privacy required
- ✅ Offline/air-gapped environments
- ✅ No API costs
- ✅ Zero vendor dependency
### How to Enable
1. Settings → Browser Transcription
2. Toggle "Enable browser transcription" ON
3. (Optional) Click "Pre-download model" if you want to cache it first
4. Start recording - transcription happens automatically after recording
### Models Available
- **Tiny** (~39MB) - Fast, good for short clips (2-3 seconds)
- **Base** (~74MB) - Balanced accuracy and speed (3-5 seconds)
- **Small** (~244MB) - Best quality, slower (6-10 seconds)
### Performance
- Transcribes ~30-second clip in 2-10 seconds (depending on model)
- First run may be slower (model loading)
- Subsequent runs are instant (cached)
### Privacy
- ✅ Audio never transmitted
- ✅ Models run locally in WASM
- ✅ No network calls during transcription
- ✅ HIPAA-compliant
---
## Option 2: Server Transcription (Cloud, Fast)
### What It Is
- Sends audio to your configured AI provider
- Uses Google Gemini, AWS Transcribe, OpenAI Whisper, or LiteLLM
### When to Use
- ✅ Maximum speed (~1 second for 30-second clip)
- ✅ Best accuracy (cloud models)
- ✅ Long recordings (Browser Whisper can be slow for 5+ minutes)
- ✅ HIPAA-compliant with BAA providers
### HIPAA-Eligible Providers
- **Google Vertex AI** (with BAA) ✅
- **AWS Transcribe** (with BAA) ✅
- **Azure OpenAI** (with BAA) ✅
- **OpenAI Whisper Direct** ❌ Not HIPAA-eligible
### How to Enable
- Configured via environment variables (`.env`)
- No user action needed - just works if API keys present
- Falls back automatically if Browser Whisper fails
### Cost
- Google Gemini: ~$0.005/minute
- AWS Transcribe: ~$0.024/minute
- OpenAI: $0.006/minute
---
## Option 3: Web Speech API (Real-Time, Experimental) ⚠️
### What It Is
- Uses your browser's built-in speech recognition
- Shows transcription **in real-time** as you speak (streaming)
- Chrome/Edge → Google Cloud Speech
- Safari → Apple Speech Recognition
### ⚠️ PRIVACY WARNING
- **Audio IS sent to cloud servers** (Google, Apple, etc.)
- **NOT HIPAA-compliant**
- Only use for non-clinical, personal use
### When to Use
- ✅ Personal notes (non-clinical)
- ✅ Want real-time feedback while speaking
- ✅ Demonstration/testing
- ❌ **NEVER for patient data**
### How to Enable
1. Settings → Real-Time Streaming Transcription
2. Read privacy warning carefully
3. Toggle "Enable real-time streaming" ON
4. Confirm warning dialog
5. Grants microphone permission
6. Start recording - see words appear live
### Limitations
- Not available in all browsers (requires Web Speech API)
- Accuracy varies by browser
- Requires internet connection
- May have usage limits
---
## Choosing the Right Option
### For Clinical Use (HIPAA Required)
**Use:** Browser Whisper (offline) OR Server (Vertex AI/AWS with BAA)
- Browser Whisper: Maximum privacy, no costs
- Server: Faster, better for long recordings
### For Personal Use (Non-HIPAA)
**Use:** Any option
- Browser Whisper: Best balance of privacy and accuracy
- Server: Fastest
- Web Speech: Real-time feedback
### Decision Tree
```
Is this clinical/patient data?
├─ YES → Use Browser Whisper or Server (Vertex/AWS)
│ ├─ Need offline? → Browser Whisper
│ ├─ Need speed? → Server (Vertex AI)
│ └─ Want free? → Browser Whisper
└─ NO → Any option
├─ Want real-time? → Web Speech API
├─ Want privacy? → Browser Whisper
└─ Want speed? → Server
```
---
## Configuration ## Configuration
### Browser Whisper ```env
```bash TRANSCRIBE_PROVIDER=litellm
# No configuration needed - bundled in Docker image LITELLM_API_BASE=https://your-litellm.example/v1
# Models at: /app/public/models/Xenova/whisper-tiny.en/ LITELLM_API_KEY=<key>
LITELLM_STT_MODEL=local-parakeet-v3
``` ```
### Server Transcription ## Failure Handling
```bash
# .env file
TRANSCRIBE_PROVIDER=google # google, aws, openai, litellm
# Google Vertex AI - Server transcription failures can create encrypted audio backups for retry.
GOOGLE_VERTEX_PROJECT=your-project-id - Users can retry or delete failed backups from Settings.
GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json - Web Speech interim text is not a substitute for a server transcription response.
# AWS Transcribe ## Removed Paths
AWS_BEDROCK_REGION=us-east-1
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
# OpenAI These should remain absent unless the project intentionally reintroduces browser-local STT with a new design review:
OPENAI_API_KEY=sk-...
# LiteLLM (proxy) - `public/js/browserWhisper.js`
LITELLM_API_BASE=http://localhost:4000 - `public/js/whisperWorker.js`
LITELLM_API_KEY=optional - `public/js/whisperWorkerV2.js`
``` - `public/models/Xenova/*`
- Browser Whisper setup/troubleshooting docs
### Web Speech API - Whisper model download scripts for public browser models
```bash
# No configuration - uses browser built-in
# Privacy warning shown in Settings UI
```
---
## FAQ
### Q: Which is most accurate?
**A:** Browser Whisper and Server (Gemini/Whisper) are equally accurate. Web Speech is slightly less accurate.
### Q: Which is fastest?
**A:** Server transcription (~1s) > Web Speech (real-time) > Browser Whisper (2-10s)
### Q: Which is most private?
**A:** Browser Whisper (100% offline) > Server (with BAA) > Web Speech (not private)
### Q: Can I use multiple at once?
**A:** No. Priority: Web Speech > Browser Whisper > Server (whichever is enabled first)
### Q: What if transcription fails?
**A:** Automatic fallback chain:
1. Browser Whisper (if enabled)
2. Falls back to Server (if configured)
3. Falls back to live transcript (if available)
### Q: Is Browser Whisper really offline?
**A:** Yes! Models are bundled in the Docker image. After the page loads once, transcription works with zero network access.
### Q: Does Web Speech work offline?
**A:** No. It requires internet to send audio to cloud servers.
### Q: Can I train/customize the models?
**A:** No. Browser Whisper uses pre-trained models. Server transcription uses cloud models. No custom training available.
---
## Troubleshooting
### Browser Whisper stuck at "Initializing"
- **Cause:** Models not loaded or network blocked during initial download
- **Fix:** See [browser-whisper-troubleshooting.md](browser-whisper-troubleshooting.md)
### Server transcription returns "No provider"
- **Cause:** API keys not configured
- **Fix:** Set environment variables in `.env`
### Web Speech says "Not supported"
- **Cause:** Browser doesn't support Web Speech API
- **Fix:** Use Chrome, Edge, or Safari
### Transcription is slow
- **Browser Whisper:** Try switching to "Tiny" model
- **Server:** Check API provider status
- **Web Speech:** Check internet connection
---
## Best Practices
### Clinical Documentation
1. Use Browser Whisper for all patient data
2. Enable audio backups (automatic in v2)
3. Keep recordings under 5 minutes for faster processing
4. Use "Tiny" model for quick notes, "Base" for detailed documentation
### Personal Use
1. Web Speech for quick, informal notes
2. Browser Whisper for anything you want private
3. Server for long recordings
### Performance Optimization
1. Pre-download Browser Whisper model before first use
2. Use shorter clips (30-60 seconds) for fastest results
3. Clear browser cache if models seem corrupted
---
## Summary
| Need | Recommendation |
|------|---------------|
| Clinical/HIPAA | Browser Whisper (offline) |
| Fast transcription | Server (Vertex AI) |
| Real-time feedback | Web Speech (non-clinical only) |
| Maximum privacy | Browser Whisper |
| Zero cost | Browser Whisper |
| Long recordings | Server (faster for 5+ min clips) |
| Offline use | Browser Whisper |
**Default recommendation:** Browser Whisper for 95% of use cases. It's private, accurate, free, and offline. Only use alternatives when you have specific needs for speed or real-time feedback.

View file

@ -27,7 +27,6 @@ const USE_REAL_AI = process.env.E2E_USE_REAL_AI === '1' || process.env.E2E_USE_R
// message matches one of these patterns it does NOT fail the test. // message matches one of these patterns it does NOT fail the test.
const CONSOLE_ERROR_ALLOWLIST = [ const CONSOLE_ERROR_ALLOWLIST = [
/favicon/i, /favicon/i,
/Failed to load resource.*models\/Xenova/i, // Browser Whisper models lazy-loaded on demand
/\/api\/models/i, // When no AI provider configured yet /\/api\/models/i, // When no AI provider configured yet
/Cross-Origin-Opener-Policy/i, // Chrome warning on non-HTTPS e2e server /Cross-Origin-Opener-Policy/i, // Chrome warning on non-HTTPS e2e server
/Failed to load resource.*(400|401|403|404|500|502|503)/i, // Any HTTP error on subsidiary fetches — smoke tests only verify UI renders, deeper integration tests validate endpoint contracts separately /Failed to load resource.*(400|401|403|404|500|502|503)/i, // Any HTTP error on subsidiary fetches — smoke tests only verify UI renders, deeper integration tests validate endpoint contracts separately

View file

@ -21,10 +21,7 @@ test.describe('Unauthenticated auth screen', () => {
}); });
test('register link is present but currently disabled (display:none)', async ({ page }) => { test('register link is present but currently disabled (display:none)', async ({ page }) => {
// Daniel's instance has invite-only registration — the "Create account" // Invite-only registration hides the link while keeping the form in the DOM.
// link is explicitly hidden via inline style, so the HTML is there but
// users can't reach the register form through the UI. Verify the hidden
// state so flipping the style to re-enable it fails loudly.
await page.goto(E2E_BASE + '/'); await page.goto(E2E_BASE + '/');
await page.waitForSelector('#auth-screen', { timeout: 10000 }); await page.waitForSelector('#auth-screen', { timeout: 10000 });
const display = await page.locator('#show-register').evaluate(el => el.style.display); const display = await page.locator('#show-register').evaluate(el => el.style.display);

View file

@ -2,14 +2,15 @@
// SESSION PERSISTENCE — full logout → login → still on the same // SESSION PERSISTENCE — full logout → login → still on the same
// tab + same sub-pill. // tab + same sub-pill.
// //
// The UI's login form is gated by a Cloudflare Turnstile token // The test does a programmatic logout (clear the ped_auth cookie,
// whose site key is hardcoded in index.html, which can't be // same effect server-side as clicking Logout) followed by a fresh
// completed in the e2e container (Turnstile rejects the non-prod // programmatic login. This exercises the same localStorage
// origin). So the test does a programmatic logout (clear the // persistence path a real logout/login would.
// ped_auth cookie, same effect server-side as clicking Logout) //
// followed by a fresh programmatic login — this exercises the // (Historically this was a workaround for the Turnstile challenge on
// same localStorage persistence path a real logout/login would, // the login form, which could not be completed in the e2e container.
// without depending on the bot challenge. // Login is no longer gated, but driving it programmatically keeps
// the test focused on persistence rather than form mechanics.)
// ============================================================ // ============================================================
const { test, expect, E2E_BASE, loginAs } = require('../fixtures'); const { test, expect, E2E_BASE, loginAs } = require('../fixtures');

View file

@ -1,12 +1,4 @@
/** // Adds soft-delete support for personal_notes via deleted_at.
* Soft-delete for personal_notes Daniel asked for "deleted notes go to
* trash" so a slip of the finger doesn't lose work. Adds a deleted_at
* timestamp; NULL means active. Trash listing filters by NOT NULL,
* regular listing filters by NULL.
*
* Restore = clear deleted_at. Empty Trash = real DELETE. No retention
* policy yet items stay in trash until the user empties it.
*/
exports.up = (pgm) => { exports.up = (pgm) => {
pgm.addColumn('personal_notes', { pgm.addColumn('personal_notes', {

View file

@ -0,0 +1,24 @@
/**
* Mermaid Diagrams per-user clinical pathway / algorithm diagrams.
* Source is plain Mermaid text; rendered to SVG client-side. Source
* encrypted at rest like personal_notes so a row dump stays useless
* without the app key.
*/
exports.up = (pgm) => {
pgm.createTable('mermaid_diagrams', {
id: { type: 'serial', primaryKey: true },
user_id: { type: 'integer', notNull: true, references: 'users(id)', onDelete: 'CASCADE' },
title: { type: 'text', notNull: true },
source: { type: 'text', notNull: true, default: '' },
notes: { type: 'text', notNull: true, default: '' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
});
pgm.createIndex('mermaid_diagrams', 'user_id');
pgm.createIndex('mermaid_diagrams', ['user_id', 'updated_at']);
};
exports.down = (pgm) => {
pgm.dropTable('mermaid_diagrams');
};

View file

@ -0,0 +1,21 @@
/**
* Optional saved clinical assistant chats. These are user-triggered saves,
* encrypted at rest like personal_notes because answers may contain PHI.
*/
exports.up = (pgm) => {
pgm.createTable('clinical_assistant_chats', {
id: { type: 'serial', primaryKey: true },
user_id: { type: 'integer', notNull: true, references: 'users(id)', onDelete: 'CASCADE' },
title: { type: 'text', notNull: true },
payload: { type: 'text', notNull: true, default: '{}' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
});
pgm.createIndex('clinical_assistant_chats', 'user_id');
pgm.createIndex('clinical_assistant_chats', ['user_id', 'updated_at']);
};
exports.down = (pgm) => {
pgm.dropTable('clinical_assistant_chats');
};

View file

@ -1,10 +1,10 @@
# PedScribe Mobile App # PedScribe Mobile App
Native mobile wrapper for Pediatric AI Scribe using Capacitor. Provides background audio recording, push notifications, haptic feedback, deep linking, and share intent support on both iOS and Android. Capacitor mobile wrapper for the hosted Ped-AI web app. The app defaults to `https://app.pedshub.com`, lets users choose a self-hosted server URL, and keeps clinical workflows API-backed through the same Express service as the browser app.
## Features ## Features
- Background recording that survives screen lock (foreground service on Android, background audio on iOS) - Hosted web workflow inside a native WebView; server updates reach mobile clients without app-store releases
- Configurable server URL (supports self-hosted instances) - Configurable server URL (supports self-hosted instances)
- Haptic feedback on recording start/stop - Haptic feedback on recording start/stop
- Keep screen awake during recording - Keep screen awake during recording
@ -15,7 +15,7 @@ Native mobile wrapper for Pediatric AI Scribe using Capacitor. Provides backgrou
stored in iOS Keychain / Android Keystore, gated by OS biometric. stored in iOS Keychain / Android Keystore, gated by OS biometric.
Enrolled on first password sign-in (opt-in prompt). 2FA still applies Enrolled on first password sign-in (opt-in prompt). 2FA still applies
on top — biometric replaces the password step only. on top — biometric replaces the password step only.
- App Store and Play Store ready - Android and iOS project scaffolds for store builds
## Prerequisites ## Prerequisites

View file

@ -9,8 +9,8 @@ android {
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
// Version values below are overwritten by scripts/release.sh from // Version values below are overwritten by scripts/release.sh from
// the root package.json. versionCode auto-increments per release. // the root package.json. versionCode auto-increments per release.
versionCode 703000 versionCode 714016
versionName "7.3.0" versionName "7.14.16"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -1,11 +1,25 @@
package com.pedshub.scribe; package com.pedshub.scribe;
import android.Manifest; import android.Manifest;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.Environment;
import android.print.PrintAttributes;
import android.print.PrintDocumentAdapter;
import android.print.PrintManager;
import android.provider.MediaStore;
import android.util.Base64;
import android.view.WindowManager;
import android.webkit.CookieManager;
import android.webkit.PermissionRequest; import android.webkit.PermissionRequest;
import android.webkit.WebChromeClient; import android.webkit.WebChromeClient;
import android.webkit.WebViewClient;
import android.webkit.WebView; import android.webkit.WebView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@ -14,10 +28,20 @@ import androidx.core.content.ContextCompat;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStream;
public class MainActivity extends BridgeActivity { public class MainActivity extends BridgeActivity {
private static final int MIC_PERMISSION_CODE = 1001; private static final int MIC_PERMISSION_CODE = 1001;
private PermissionRequest pendingPermissionRequest; private PermissionRequest pendingPermissionRequest;
private WebView printWebView;
// True between startForegroundService() and stopForegroundService(), i.e.
// while the web app has an active MediaRecorder. Drives the keep-screen-on
// flag and the timer-throttling workaround below.
private volatile boolean recordingActive = false;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
@ -30,11 +54,93 @@ public class MainActivity extends BridgeActivity {
new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE); new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE);
} }
// Allow the Cloudflare Turnstile iframe to use storage.
setupThirdPartyCookies();
// Setup WebView mic permission granting // Setup WebView mic permission granting
setupWebViewPermissions(); setupWebViewPermissions();
// Register JS interface for foreground service control // Register JS interface for foreground service control
setupRecordingBridge(); setupRecordingBridge();
// Register JS interface for Android's print / Save as PDF flow.
setupPrintBridge();
// Register JS interface for saving generated visuals to Photos.
setupFileBridge();
}
// Recording Lifecycle
//
// Recording happens in the WebView (MediaRecorder), not in native code,
// so keeping the foreground service alive is necessary but not sufficient
// the WebView also has to keep executing JS. Two things protect that:
//
// 1. FLAG_KEEP_SCREEN_ON while recording, so the device does not
// auto-lock mid-encounter. This is the case that actually bites
// clinicians: a long pause in conversation and the screen times out.
//
// 2. resumeTimers() if the activity is paused anyway (user presses the
// power button, or a call comes in). Chromium throttles timers hard
// for hidden WebViews, which starves MediaRecorder's chunk delivery.
// Capacitor never calls webView.onPause(), so the WebView itself is
// still live it is only the timers that need rescuing.
//
// Note resumeTimers()/pauseTimers() are process-global in WebView, not
// per-instance; calling resume here is safe because this app has no other
// WebView that wants throttling (printWebView is transient).
void setKeepScreenOn(final boolean on) {
runOnUiThread(() -> {
if (on) {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} else {
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
});
}
void setRecordingActive(boolean active) {
recordingActive = active;
setKeepScreenOn(active);
}
// NB: BridgeActivity declares these public narrowing to protected would
// not compile.
@Override
public void onPause() {
super.onPause();
if (recordingActive && this.bridge != null && this.bridge.getWebView() != null) {
this.bridge.getWebView().resumeTimers();
}
}
@Override
public void onResume() {
super.onResume();
if (this.bridge != null && this.bridge.getWebView() != null) {
this.bridge.getWebView().resumeTimers();
}
}
// Third-Party Cookies
//
// Android WebView blocks third-party cookies by default (unlike Chrome,
// which still allows them for now). Cloudflare Turnstile runs inside a
// cross-origin iframe from challenges.cloudflare.com and needs its own
// storage to run and persist a challenge without this the widget
// silently stalls or errors and never emits a token, so registration and
// password reset are impossible from inside the app.
//
// This is scoped to our own WebView, which only ever loads the PedScribe
// origin (see allowNavigation in capacitor.config.json), so it is not a
// general relaxation of the app's cookie policy.
private void setupThirdPartyCookies() {
WebView webView = this.bridge.getWebView();
CookieManager cookieManager = CookieManager.getInstance();
cookieManager.setAcceptCookie(true);
cookieManager.setAcceptThirdPartyCookies(webView, true);
} }
// WebView Microphone Permission // WebView Microphone Permission
@ -80,6 +186,16 @@ public class MainActivity extends BridgeActivity {
webView.addJavascriptInterface(new RecordingBridge(this), "NativeRecording"); webView.addJavascriptInterface(new RecordingBridge(this), "NativeRecording");
} }
private void setupPrintBridge() {
WebView webView = this.bridge.getWebView();
webView.addJavascriptInterface(new PrintBridge(this), "NativePrint");
}
private void setupFileBridge() {
WebView webView = this.bridge.getWebView();
webView.addJavascriptInterface(new FileBridge(this), "NativeFiles");
}
public static class RecordingBridge { public static class RecordingBridge {
private final MainActivity activity; private final MainActivity activity;
@ -91,6 +207,7 @@ public class MainActivity extends BridgeActivity {
public void startForegroundService() { public void startForegroundService() {
Intent intent = new Intent(activity, AudioRecordingService.class); Intent intent = new Intent(activity, AudioRecordingService.class);
ContextCompat.startForegroundService(activity, intent); ContextCompat.startForegroundService(activity, intent);
activity.setRecordingActive(true);
} }
@android.webkit.JavascriptInterface @android.webkit.JavascriptInterface
@ -98,6 +215,108 @@ public class MainActivity extends BridgeActivity {
Intent intent = new Intent(activity, AudioRecordingService.class); Intent intent = new Intent(activity, AudioRecordingService.class);
intent.setAction(AudioRecordingService.ACTION_STOP); intent.setAction(AudioRecordingService.ACTION_STOP);
activity.startService(intent); activity.startService(intent);
activity.setRecordingActive(false);
}
// Standalone keep-awake, exposed so the web app can hold the screen on
// for non-recording work too. window.nativeKeepAwake() previously
// called Capacitor's KeepAwake plugin, which is not installed in this
// project so it silently did nothing and the screen slept during
// recordings.
@android.webkit.JavascriptInterface
public void keepAwake(boolean on) {
activity.setKeepScreenOn(on);
} }
} }
public static class PrintBridge {
private final MainActivity activity;
PrintBridge(MainActivity activity) {
this.activity = activity;
}
@android.webkit.JavascriptInterface
public void printHtml(String title, String base64Html) {
activity.runOnUiThread(() -> activity.printHtmlFromBase64(title, base64Html));
}
}
public static class FileBridge {
private final MainActivity activity;
FileBridge(MainActivity activity) {
this.activity = activity;
}
@android.webkit.JavascriptInterface
public String saveImage(String filename, String base64Png) {
return activity.saveImageToPictures(filename, base64Png);
}
}
private void printHtmlFromBase64(String title, String base64Html) {
try {
byte[] decoded = Base64.decode(base64Html, Base64.DEFAULT);
String html = new String(decoded, java.nio.charset.StandardCharsets.UTF_8);
printWebView = new WebView(this);
printWebView.setWebViewClient(new WebViewClient() {
@Override
public void onPageFinished(WebView view, String url) {
PrintManager printManager = (PrintManager) getSystemService(Context.PRINT_SERVICE);
PrintDocumentAdapter adapter = view.createPrintDocumentAdapter(title != null && !title.isEmpty() ? title : "Clinical Assistant Export");
printManager.print(title != null && !title.isEmpty() ? title : "Clinical Assistant Export", adapter, new PrintAttributes.Builder().build());
}
});
printWebView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null);
} catch (Exception e) {
android.util.Log.e("PedScribe", "Native print failed", e);
}
}
private String saveImageToPictures(String filename, String base64Png) {
String safeName = sanitizeFilename(filename, "clinical-visual.png");
try {
byte[] imageBytes = Base64.decode(base64Png, Base64.DEFAULT);
Uri uri;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContentResolver resolver = getContentResolver();
ContentValues values = new ContentValues();
values.put(MediaStore.Images.Media.DISPLAY_NAME, safeName);
values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
values.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/PedScribe");
values.put(MediaStore.Images.Media.IS_PENDING, 1);
uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
if (uri == null) return "error:Could not create image file";
try (OutputStream out = resolver.openOutputStream(uri)) {
if (out == null) return "error:Could not open image file";
out.write(imageBytes);
}
values.clear();
values.put(MediaStore.Images.Media.IS_PENDING, 0);
resolver.update(uri, values, null, null);
} else {
File dir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), "PedScribe");
if (!dir.exists() && !dir.mkdirs()) return "error:Could not create Pictures/PedScribe";
File file = new File(dir, safeName);
try (OutputStream out = new FileOutputStream(file)) {
out.write(imageBytes);
}
uri = Uri.fromFile(file);
sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri));
}
return "saved:" + uri.toString();
} catch (Exception e) {
android.util.Log.e("PedScribe", "Native image save failed", e);
return "error:" + (e.getMessage() != null ? e.getMessage() : "Image save failed");
}
}
private String sanitizeFilename(String filename, String fallback) {
String value = filename != null ? filename : fallback;
value = value.replaceAll("[^A-Za-z0-9._-]", "-");
if (value.length() == 0) value = fallback;
if (!value.toLowerCase(java.util.Locale.US).endsWith(".png")) value = value + ".png";
return value;
}
} }

View file

@ -13,7 +13,7 @@
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar"> <style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item> <item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item> <item name="windowNoTitle">true</item>
<item name="android:background">@null</item> <item name="android:background">@color/colorPrimary</item>
<item name="android:statusBarColor">@color/colorPrimaryDark</item> <item name="android:statusBarColor">@color/colorPrimaryDark</item>
<item name="android:navigationBarColor">@color/colorPrimaryDark</item> <item name="android:navigationBarColor">@color/colorPrimaryDark</item>
</style> </style>

View file

@ -2,4 +2,6 @@
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." /> <external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." /> <cache-path name="my_cache_images" path="." />
<files-path name="my_files" path="." />
<external-files-path name="my_external_files" path="." />
</paths> </paths>

View file

@ -0,0 +1,2 @@
json_key_file('fastlane/google-play-service-account.json')
package_name('com.pedshub.scribe')

View file

@ -0,0 +1,18 @@
default_platform(:android)
platform :android do
desc "Upload a signed release AAB to Google Play internal track"
lane :publish_internal do
upload_to_play_store(
package_name: 'com.pedshub.scribe',
json_key: 'fastlane/google-play-service-account.json',
aab: ENV['AAB_PATH'] || 'app/build/outputs/bundle/release/app-release.aab',
track: ENV['PLAY_TRACK'] || 'internal',
skip_upload_changelogs: true,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true,
release_status: 'completed',
)
end
end

View file

@ -0,0 +1,3 @@
source 'https://rubygems.org'
gem 'fastlane'

View file

@ -1,18 +1,19 @@
{ {
"name": "pedscribe-mobile", "name": "pedscribe-mobile",
"version": "1.0.0", "version": "7.14.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pedscribe-mobile", "name": "pedscribe-mobile",
"version": "1.0.0", "version": "7.14.14",
"dependencies": { "dependencies": {
"@aparajita/capacitor-biometric-auth": "^8.0.0", "@aparajita/capacitor-biometric-auth": "^8.0.0",
"@capacitor/android": "^6.0.0", "@capacitor/android": "^6.0.0",
"@capacitor/app": "^6.0.0", "@capacitor/app": "^6.0.0",
"@capacitor/cli": "^6.0.0", "@capacitor/cli": "^6.0.0",
"@capacitor/core": "^6.0.0", "@capacitor/core": "^6.0.0",
"@capacitor/filesystem": "^6.0.4",
"@capacitor/haptics": "^6.0.0", "@capacitor/haptics": "^6.0.0",
"@capacitor/ios": "^6.0.0", "@capacitor/ios": "^6.0.0",
"@capacitor/keyboard": "^6.0.0", "@capacitor/keyboard": "^6.0.0",
@ -20,7 +21,8 @@
"@capacitor/screen-orientation": "^6.0.0", "@capacitor/screen-orientation": "^6.0.0",
"@capacitor/share": "^6.0.0", "@capacitor/share": "^6.0.0",
"@capacitor/splash-screen": "^6.0.0", "@capacitor/splash-screen": "^6.0.0",
"@capacitor/status-bar": "^6.0.0" "@capacitor/status-bar": "^6.0.0",
"capacitor-secure-storage-plugin": "^0.10.0"
} }
}, },
"node_modules/@aparajita/capacitor-biometric-auth": { "node_modules/@aparajita/capacitor-biometric-auth": {
@ -97,6 +99,15 @@
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
}, },
"node_modules/@capacitor/filesystem": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-6.0.4.tgz",
"integrity": "sha512-eFlg/ZrwYA4Y6ClLRRikudVu2XvuZxfX/XC0ky9MgfbC9dyqTnVkkEoWM6vr1xR89YNY4mB0EeVTet1m1Jcumw==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^6.0.0"
}
},
"node_modules/@capacitor/haptics": { "node_modules/@capacitor/haptics": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-6.0.3.tgz", "resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-6.0.3.tgz",
@ -488,6 +499,15 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/capacitor-secure-storage-plugin": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/capacitor-secure-storage-plugin/-/capacitor-secure-storage-plugin-0.10.0.tgz",
"integrity": "sha512-dV4E+HTZAJWC3gef7sBXaAkkb6wvcZHyXjJIHXNb3yz9gRQ/5VMLqCxa0khqpwgWh5oIbo4XFxg3g5tEkfaNMg==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^6.0.0"
}
},
"node_modules/chownr": { "node_modules/chownr": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",

View file

@ -1,6 +1,6 @@
{ {
"name": "pedscribe-mobile", "name": "pedscribe-mobile",
"version": "7.3.0", "version": "7.14.16",
"description": "PedScribe native mobile app — Capacitor wrapper for Pediatric AI Scribe", "description": "PedScribe native mobile app — Capacitor wrapper for Pediatric AI Scribe",
"private": true, "private": true,
"scripts": { "scripts": {
@ -11,19 +11,20 @@
"build:ios": "npx cap sync ios" "build:ios": "npx cap sync ios"
}, },
"dependencies": { "dependencies": {
"@aparajita/capacitor-biometric-auth": "^8.0.0",
"@capacitor/android": "^6.0.0", "@capacitor/android": "^6.0.0",
"@capacitor/app": "^6.0.0", "@capacitor/app": "^6.0.0",
"@capacitor/cli": "^6.0.0", "@capacitor/cli": "^6.0.0",
"@capacitor/core": "^6.0.0", "@capacitor/core": "^6.0.0",
"@capacitor/ios": "^6.0.0", "@capacitor/filesystem": "^6.0.4",
"@capacitor/haptics": "^6.0.0", "@capacitor/haptics": "^6.0.0",
"@capacitor/ios": "^6.0.0",
"@capacitor/keyboard": "^6.0.0", "@capacitor/keyboard": "^6.0.0",
"@capacitor/push-notifications": "^6.0.0", "@capacitor/push-notifications": "^6.0.0",
"@capacitor/screen-orientation": "^6.0.0", "@capacitor/screen-orientation": "^6.0.0",
"@capacitor/share": "^6.0.0", "@capacitor/share": "^6.0.0",
"@capacitor/splash-screen": "^6.0.0", "@capacitor/splash-screen": "^6.0.0",
"@capacitor/status-bar": "^6.0.0", "@capacitor/status-bar": "^6.0.0",
"capacitor-native-biometric": "^5.0.0",
"capacitor-secure-storage-plugin": "^0.10.0" "capacitor-secure-storage-plugin": "^0.10.0"
} }
} }

144
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "pediatric-ai-scribe", "name": "pediatric-ai-scribe",
"version": "7.0.0", "version": "7.14.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pediatric-ai-scribe", "name": "pediatric-ai-scribe",
"version": "7.0.0", "version": "7.14.14",
"dependencies": { "dependencies": {
"@marp-team/marp-cli": "^4.3.1", "@marp-team/marp-cli": "^4.3.1",
"@marp-team/marp-core": "^4.3.0", "@marp-team/marp-core": "^4.3.0",
@ -28,6 +28,7 @@
"helmet": "^8.0.0", "helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mammoth": "^1.8.0", "mammoth": "^1.8.0",
"markdown-it": "^14.1.1",
"marked": "^18.0.2", "marked": "^18.0.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-pg-migrate": "^7.7.0", "node-pg-migrate": "^7.7.0",
@ -37,7 +38,9 @@
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
"pg": "^8.13.0", "pg": "^8.13.0",
"pptxgenjs": "^4.0.1", "pptxgenjs": "^4.0.1",
"prom-client": "^15.1.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"redis": "^4.7.1",
"speakeasy": "^2.0.0" "speakeasy": "^2.0.0"
}, },
"devDependencies": { "devDependencies": {
@ -2277,6 +2280,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@phc/format": { "node_modules/@phc/format": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@ -2471,6 +2483,65 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@redis/bloom": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/client": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
"yallist": "4.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@redis/graph": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/json": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/search": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/time-series": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@remirror/core-constants": { "node_modules/@remirror/core-constants": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
@ -4100,6 +4171,12 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/bintrees": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz",
"integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==",
"license": "MIT"
},
"node_modules/bluebird": { "node_modules/bluebird": {
"version": "3.4.7", "version": "3.4.7",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
@ -4276,6 +4353,15 @@
"wrap-ansi": "^6.2.0" "wrap-ansi": "^6.2.0"
} }
}, },
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -5322,6 +5408,15 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/generic-pool": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/get-caller-file": { "node_modules/get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -7296,6 +7391,19 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/prom-client": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz",
"integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.4.0",
"tdigest": "^0.1.1"
},
"engines": {
"node": "^16 || ^18 || >=20"
}
},
"node_modules/prosemirror-changeset": { "node_modules/prosemirror-changeset": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
@ -7746,6 +7854,23 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/redis": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
"license": "MIT",
"workspaces": [
"./packages/*"
],
"dependencies": {
"@redis/bloom": "1.2.0",
"@redis/client": "1.6.1",
"@redis/graph": "1.1.1",
"@redis/json": "1.0.7",
"@redis/search": "1.2.0",
"@redis/time-series": "1.1.0"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -8373,6 +8498,15 @@
"streamx": "^2.15.0" "streamx": "^2.15.0"
} }
}, },
"node_modules/tdigest": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz",
"integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==",
"license": "MIT",
"dependencies": {
"bintrees": "1.0.2"
}
},
"node_modules/teex": { "node_modules/teex": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
@ -8745,6 +8879,12 @@
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "15.4.1", "version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",

View file

@ -1,11 +1,11 @@
{ {
"name": "pediatric-ai-scribe", "name": "pediatric-ai-scribe",
"version": "7.3.0", "version": "7.14.16",
"description": "AI-powered pediatric clinical documentation platform", "description": "AI-powered pediatric clinical documentation platform",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"test": "node --test test/", "test": "node --test test/*.test.js",
"e2e": "./scripts/e2e.sh", "e2e": "./scripts/e2e.sh",
"maint:check": "node scripts/maintenance.js check", "maint:check": "node scripts/maintenance.js check",
"maint:reindex": "node scripts/maintenance.js reindex", "maint:reindex": "node scripts/maintenance.js reindex",
@ -36,6 +36,7 @@
"helmet": "^8.0.0", "helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mammoth": "^1.8.0", "mammoth": "^1.8.0",
"markdown-it": "^14.1.1",
"marked": "^18.0.2", "marked": "^18.0.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-pg-migrate": "^7.7.0", "node-pg-migrate": "^7.7.0",
@ -45,7 +46,9 @@
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
"pg": "^8.13.0", "pg": "^8.13.0",
"pptxgenjs": "^4.0.1", "pptxgenjs": "^4.0.1",
"prom-client": "^15.1.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"redis": "^4.7.1",
"speakeasy": "^2.0.0" "speakeasy": "^2.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -271,7 +271,7 @@
<div style="border-top:1px solid var(--g100);padding-top:14px;"> <div style="border-top:1px solid var(--g100);padding-top:14px;">
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Discover Models from Provider API</label> <label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Discover Models from Provider API</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;"> <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<input type="text" id="admin-model-search" placeholder="Search models (e.g. gemini, vendor-model, gpt)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:200px;"> <input type="text" id="admin-model-search" placeholder="Search models (e.g. gemini, gpt, llama)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:200px;">
<button id="btn-discover-models" class="btn-sm btn-primary"><i class="fas fa-magnifying-glass"></i> Search API</button> <button id="btn-discover-models" class="btn-sm btn-primary"><i class="fas fa-magnifying-glass"></i> Search API</button>
</div> </div>
<div id="admin-discovered-models" style="margin-top:10px;display:flex;flex-direction:column;gap:4px;max-height:400px;overflow-y:auto;"> <div id="admin-discovered-models" style="margin-top:10px;display:flex;flex-direction:column;gap:4px;max-height:400px;overflow-y:auto;">
@ -318,6 +318,17 @@
<button id="btn-test-assistant-chat-model" class="btn-sm btn-primary" type="button"><i class="fas fa-vial"></i> Test</button> <button id="btn-test-assistant-chat-model" class="btn-sm btn-primary" type="button"><i class="fas fa-vial"></i> Test</button>
</div> </div>
<div id="assistant-chat-test-result" style="font-size:12px;color:var(--g500);"></div> <div id="assistant-chat-test-result" style="font-size:12px;color:var(--g500);"></div>
<div style="border:1px solid var(--g100);border-radius:8px;padding:10px;display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;">
<div>
<div style="font-size:13px;font-weight:600;color:var(--g700);">Starter prompt pool</div>
<div id="assistant-prompt-pool-status" style="font-size:12px;color:var(--g500);margin-top:3px;">Checking prompt pool...</div>
</div>
<button id="btn-regenerate-assistant-prompt-pool" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate"></i> Regenerate Pool</button>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;width:100%;">
<select id="assistant-prompt-pool-snapshots" style="font-size:12px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:220px;"><option value="">Loading snapshots...</option></select>
<button id="btn-restore-assistant-prompt-pool" class="btn-sm btn-ghost" type="button"><i class="fas fa-clock-rotate-left"></i> Restore Snapshot</button>
</div>
</div>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<label style="font-size:13px;font-weight:600;min-width:130px;">Image model:</label> <label style="font-size:13px;font-weight:600;min-width:130px;">Image model:</label>
<select id="assistant-image-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;"></select> <select id="assistant-image-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;"></select>
@ -325,6 +336,11 @@
<button id="btn-test-assistant-image-model" class="btn-sm btn-primary" type="button"><i class="fas fa-image"></i> Test</button> <button id="btn-test-assistant-image-model" class="btn-sm btn-primary" type="button"><i class="fas fa-image"></i> Test</button>
</div> </div>
<div id="assistant-image-test-result" style="font-size:12px;color:var(--g500);"></div> <div id="assistant-image-test-result" style="font-size:12px;color:var(--g500);"></div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:-6px;">
<label style="font-size:12px;font-weight:600;color:var(--g600);min-width:130px;">Custom image model</label>
<input id="assistant-custom-image-model" type="text" placeholder="e.g. openrouter-gpt-5-image" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;">
<button id="btn-use-custom-assistant-image-model" class="btn-sm btn-ghost" type="button"><i class="fas fa-plus"></i> Use Custom</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div> <div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Search result limit</label> <label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Search result limit</label>

View file

@ -19,6 +19,7 @@
<div class="assistant-toolbar-actions"> <div class="assistant-toolbar-actions">
<button id="btn-assistant-clear" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate-left"></i> Clear</button> <button id="btn-assistant-clear" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate-left"></i> Clear</button>
<button id="btn-assistant-copy" class="btn-sm btn-ghost" type="button"><i class="fas fa-copy"></i> Copy answer</button> <button id="btn-assistant-copy" class="btn-sm btn-ghost" type="button"><i class="fas fa-copy"></i> Copy answer</button>
<button id="btn-assistant-save" class="btn-sm btn-ghost" type="button"><i class="fas fa-bookmark"></i> Save chat</button>
<button id="btn-assistant-export-pdf" class="btn-sm btn-ghost" type="button"><i class="fas fa-file-pdf"></i> Export PDF</button> <button id="btn-assistant-export-pdf" class="btn-sm btn-ghost" type="button"><i class="fas fa-file-pdf"></i> Export PDF</button>
</div> </div>
</div> </div>
@ -31,7 +32,7 @@
<div class="assistant-examples"> <div class="assistant-examples">
<button type="button" data-assistant-example="In a 4-year-old with acute wheeze, when should magnesium sulfate be considered and what dose is recommended?">Status asthma escalation</button> <button type="button" data-assistant-example="In a 4-year-old with acute wheeze, when should magnesium sulfate be considered and what dose is recommended?">Status asthma escalation</button>
<button type="button" data-assistant-example="What are the red flags for bilious vomiting in neonates, and what immediate workup is recommended?">Bilious vomiting</button> <button type="button" data-assistant-example="What are the red flags for bilious vomiting in neonates, and what immediate workup is recommended?">Bilious vomiting</button>
<button type="button" data-assistant-example="Compare bronchiolitis and asthma management in infants, citing pediatric references.">Bronchiolitis vs asthma</button> <button type="button" data-assistant-example="Compare bronchiolitis and asthma management in infants, citing clinical references.">Bronchiolitis vs asthma</button>
</div> </div>
</div> </div>
</div> </div>
@ -40,6 +41,7 @@
<textarea id="assistant-input" rows="3" placeholder="Ask a focused clinical question..." autocomplete="off"></textarea> <textarea id="assistant-input" rows="3" placeholder="Ask a focused clinical question..." autocomplete="off"></textarea>
<div class="assistant-composer-footer"> <div class="assistant-composer-footer">
<label class="assistant-check"><input type="checkbox" id="assistant-include-context" checked> retrieve broader context</label> <label class="assistant-check"><input type="checkbox" id="assistant-include-context" checked> retrieve broader context</label>
<button id="btn-assistant-cancel" class="btn-sm btn-ghost" type="button" hidden><i class="fas fa-stop"></i> Cancel search</button>
<button id="btn-assistant-send" class="btn-generate" type="submit"><i class="fas fa-paper-plane"></i> Ask</button> <button id="btn-assistant-send" class="btn-generate" type="submit"><i class="fas fa-paper-plane"></i> Ask</button>
</div> </div>
</form> </form>
@ -50,11 +52,29 @@
<div class="card-header"><h3><i class="fas fa-wand-magic-sparkles"></i> Image / Graph</h3></div> <div class="card-header"><h3><i class="fas fa-wand-magic-sparkles"></i> Image / Graph</h3></div>
<div class="assistant-side-body"> <div class="assistant-side-body">
<textarea id="assistant-image-prompt" rows="4" placeholder="Generate a teaching image, pathway, or infographic from the current answer..."></textarea> <textarea id="assistant-image-prompt" rows="4" placeholder="Generate a teaching image, pathway, or infographic from the current answer..."></textarea>
<button id="btn-assistant-image" class="btn-sm btn-primary" type="button"><i class="fas fa-image"></i> Generate image</button> <div class="assistant-image-buttons">
<button id="btn-assistant-image" class="btn-sm btn-primary" type="button"><i class="fas fa-image"></i> Generate image</button>
<button id="btn-assistant-image-clear" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate-left"></i> Clear image</button>
</div>
<div id="assistant-visual-output" class="assistant-visual-output"></div> <div id="assistant-visual-output" class="assistant-visual-output"></div>
</div> </div>
</div> </div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-bookmark"></i> Saved Chats</h3></div>
<div id="assistant-saved-chats" class="assistant-saved-chats">
<p class="assistant-muted">Saved chats appear here after you click Save chat.</p>
</div>
<div id="assistant-save-panel" class="assistant-save-panel" hidden>
<label for="assistant-save-title">Save chat as</label>
<input id="assistant-save-title" type="text" maxlength="160" autocomplete="off">
<div class="assistant-save-actions">
<button id="btn-assistant-save-confirm" class="btn-sm btn-primary" type="button">Save</button>
<button id="btn-assistant-save-cancel" class="btn-sm btn-ghost" type="button">Cancel</button>
</div>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-header"><h3><i class="fas fa-quote-right"></i> Sources</h3></div> <div class="card-header"><h3><i class="fas fa-quote-right"></i> Sources</h3></div>
<div id="assistant-sources" class="assistant-sources"> <div id="assistant-sources" class="assistant-sources">
@ -71,10 +91,11 @@
.assistant-status.busy .assistant-dot { background:var(--amber); animation:pulse 1.5s infinite; } .assistant-status.busy .assistant-dot { background:var(--amber); animation:pulse 1.5s infinite; }
.assistant-status.error .assistant-dot { background:var(--red); } .assistant-status.error .assistant-dot { background:var(--red); }
.assistant-layout { display:grid; grid-template-columns:minmax(0,1fr) 330px; gap:14px; align-items:start; } .assistant-layout { display:grid; grid-template-columns:minmax(0,1fr) 330px; gap:14px; align-items:start; }
.assistant-main { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 190px); } .assistant-layout > * { min-width:0; }
.assistant-main { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 190px); min-width:0; }
.assistant-toolbar { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--g200); background:var(--g50); } .assistant-toolbar { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--g200); background:var(--g50); }
.assistant-toolbar-actions { display:flex; gap:6px; flex-wrap:wrap; } .assistant-toolbar-actions { display:flex; gap:6px; flex-wrap:wrap; }
.assistant-messages { padding:16px; overflow-y:auto; background:linear-gradient(180deg,#fff,var(--g50)); } .assistant-messages { padding:16px; overflow-y:auto; overflow-x:hidden; background:linear-gradient(180deg,#fff,var(--g50)); min-width:0; }
.assistant-empty { max-width:680px; margin:50px auto; text-align:center; color:var(--g500); } .assistant-empty { max-width:680px; margin:50px auto; text-align:center; color:var(--g500); }
.assistant-empty i { font-size:34px; color:var(--purple); margin-bottom:10px; } .assistant-empty i { font-size:34px; color:var(--purple); margin-bottom:10px; }
.assistant-empty h3 { color:var(--g800); font-size:18px; margin-bottom:6px; } .assistant-empty h3 { color:var(--g800); font-size:18px; margin-bottom:6px; }
@ -83,10 +104,10 @@
.assistant-suggestion-buttons { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; } .assistant-suggestion-buttons { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
.assistant-suggestion-buttons button { border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; text-align:left; } .assistant-suggestion-buttons button { border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; text-align:left; }
.assistant-suggestion-buttons button:hover { border-color:var(--purple); background:var(--purple-light); } .assistant-suggestion-buttons button:hover { border-color:var(--purple); background:var(--purple-light); }
.assistant-msg { max-width:900px; margin:0 0 14px; display:grid; gap:6px; } .assistant-msg { max-width:900px; min-width:0; margin:0 0 14px; display:grid; gap:6px; }
.assistant-msg.user { margin-left:auto; max-width:760px; } .assistant-msg.user { margin-left:auto; max-width:760px; }
.assistant-msg-label { font-size:11px; font-weight:700; color:var(--g400); text-transform:uppercase; letter-spacing:.04em; } .assistant-msg-label { font-size:11px; font-weight:700; color:var(--g400); text-transform:uppercase; letter-spacing:.04em; }
.assistant-bubble { border:1px solid var(--g200); border-radius:14px; padding:12px 14px; background:white; box-shadow:var(--shadow); font-size:13px; line-height:1.75; } .assistant-bubble { border:1px solid var(--g200); border-radius:14px; padding:12px 14px; background:white; box-shadow:var(--shadow); font-size:13px; line-height:1.75; overflow-wrap:anywhere; min-width:0; max-width:100%; }
.assistant-msg.user .assistant-bubble { background:var(--blue); color:white; border-color:var(--blue); } .assistant-msg.user .assistant-bubble { background:var(--blue); color:white; border-color:var(--blue); }
.assistant-bubble h1, .assistant-bubble h2, .assistant-bubble h3 { margin:16px 0 8px; line-height:1.25; color:var(--g900); } .assistant-bubble h1, .assistant-bubble h2, .assistant-bubble h3 { margin:16px 0 8px; line-height:1.25; color:var(--g900); }
.assistant-bubble h1:first-child, .assistant-bubble h2:first-child, .assistant-bubble h3:first-child { margin-top:0; } .assistant-bubble h1:first-child, .assistant-bubble h2:first-child, .assistant-bubble h3:first-child { margin-top:0; }
@ -98,8 +119,11 @@
.assistant-bubble ul, .assistant-bubble ol { padding-left:20px; margin:8px 0; } .assistant-bubble ul, .assistant-bubble ol { padding-left:20px; margin:8px 0; }
.assistant-bubble li { margin:4px 0; } .assistant-bubble li { margin:4px 0; }
.assistant-bubble blockquote { margin:10px 0; padding:8px 12px; border-left:3px solid var(--blue); background:var(--blue-light); color:var(--g700); border-radius:8px; } .assistant-bubble blockquote { margin:10px 0; padding:8px 12px; border-left:3px solid var(--blue); background:var(--blue-light); color:var(--g700); border-radius:8px; }
.assistant-bubble table { width:100%; border-collapse:separate; border-spacing:0; margin:12px 0; overflow:hidden; border:1px solid var(--g200); border-radius:10px; font-size:12px; } .assistant-table-scroll { max-width:100%; overflow-x:auto; overflow-y:hidden; -webkit-overflow-scrolling:touch; margin:12px 0; border:1px solid var(--g200); border-radius:12px; background:white; box-shadow:inset 0 -1px 0 rgba(0,0,0,.03); }
.assistant-table-scroll table { width:max-content; min-width:100%; max-width:none; border-collapse:separate; border-spacing:0; margin:0; border:0; border-radius:0; font-size:12px; }
.assistant-table-scroll::after { content:'Swipe table'; display:none; position:sticky; left:0; bottom:0; padding:3px 9px; font-size:10px; font-weight:700; color:var(--g500); background:linear-gradient(90deg,rgba(255,255,255,.95),rgba(255,255,255,0)); pointer-events:none; }
.assistant-bubble th, .assistant-bubble td { padding:8px 10px; border-bottom:1px solid var(--g200); vertical-align:top; text-align:left; } .assistant-bubble th, .assistant-bubble td { padding:8px 10px; border-bottom:1px solid var(--g200); vertical-align:top; text-align:left; }
.assistant-bubble th, .assistant-bubble td { overflow-wrap:normal; word-break:normal; min-width:120px; }
.assistant-bubble th { background:var(--g50); font-weight:700; color:var(--g800); } .assistant-bubble th { background:var(--g50); font-weight:700; color:var(--g800); }
.assistant-bubble tr:last-child td { border-bottom:0; } .assistant-bubble tr:last-child td { border-bottom:0; }
.assistant-bubble code { background:var(--g100); border-radius:4px; padding:1px 4px; } .assistant-bubble code { background:var(--g100); border-radius:4px; padding:1px 4px; }
@ -113,28 +137,50 @@
.assistant-thinking-dot:nth-child(3) { animation-delay:.3s; margin-right:3px; } .assistant-thinking-dot:nth-child(3) { animation-delay:.3s; margin-right:3px; }
@keyframes assistantBounce { 0%,80%,100% { transform:scale(.65); opacity:.45; } 40% { transform:scale(1); opacity:1; } } @keyframes assistantBounce { 0%,80%,100% { transform:scale(.65); opacity:.45; } 40% { transform:scale(1); opacity:1; } }
@keyframes assistantShimmer { 0% { background-position:100% 0; } 100% { background-position:-100% 0; } } @keyframes assistantShimmer { 0% { background-position:100% 0; } 100% { background-position:-100% 0; } }
.assistant-cite { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 6px; border-radius:999px; background:var(--purple-light); color:var(--purple); font-size:11px; font-weight:700; text-decoration:none; } .assistant-cite { display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 6px; margin:0 1px; border-radius:999px; background:var(--purple-light); color:var(--purple); font-size:10px; font-weight:800; text-decoration:none; vertical-align:baseline; border:1px solid rgba(124,58,237,.18); text-transform:uppercase; letter-spacing:.03em; }
.assistant-cite:hover { background:var(--purple); color:white; text-decoration:none; }
.assistant-composer { border-top:1px solid var(--g200); padding:12px; background:white; display:grid; gap:8px; } .assistant-composer { border-top:1px solid var(--g200); padding:12px; background:white; display:grid; gap:8px; }
.assistant-composer textarea, .assistant-side textarea { width:100%; border:1.5px solid var(--g300); border-radius:10px; padding:10px 12px; resize:vertical; font-family:inherit; font-size:13px; outline:none; } .assistant-composer textarea, .assistant-side textarea { width:100%; border:1.5px solid var(--g300); border-radius:10px; padding:10px 12px; resize:vertical; font-family:inherit; font-size:13px; outline:none; }
.assistant-composer textarea:focus, .assistant-side textarea:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); } .assistant-composer textarea:focus, .assistant-side textarea:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); }
.assistant-composer-footer { display:flex; justify-content:space-between; align-items:center; gap:10px; } .assistant-composer-footer { display:flex; justify-content:space-between; align-items:center; gap:10px; }
.assistant-composer-footer .btn-generate { width:auto; margin:0; padding:9px 18px; } .assistant-composer-footer .btn-generate { width:auto; margin:0; padding:9px 18px; }
.assistant-composer-footer #btn-assistant-cancel[hidden] { display:none !important; }
.assistant-composer-footer #btn-assistant-cancel:not([hidden]) { display:inline-flex; }
.assistant-check { font-size:12px; color:var(--g500); display:flex; align-items:center; gap:6px; } .assistant-check { font-size:12px; color:var(--g500); display:flex; align-items:center; gap:6px; }
.assistant-side { display:grid; gap:12px; } .assistant-side { display:grid; gap:12px; }
.assistant-side-body { padding:12px; display:grid; gap:10px; font-size:13px; } .assistant-side-body { padding:12px; display:grid; gap:10px; font-size:13px; }
.assistant-visual-output { display:grid; gap:8px; } .assistant-visual-output { display:grid; gap:8px; }
.assistant-image-buttons { display:flex; gap:8px; flex-wrap:wrap; }
.assistant-visual-output img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; } .assistant-visual-output img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
.assistant-generated-image { display:grid; gap:8px; } .assistant-generated-image { display:grid; gap:8px; }
.assistant-generated-image img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; } .assistant-generated-image img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
.assistant-image-actions { display:flex; gap:8px; flex-wrap:wrap; } .assistant-image-actions { display:flex; gap:8px; flex-wrap:wrap; }
.assistant-image-preview-open { overflow:hidden; }
.assistant-image-modal { position:fixed; inset:0; z-index:9999; background:rgba(15,23,42,.82); display:flex; align-items:center; justify-content:center; padding:24px; } .assistant-image-modal { position:fixed; inset:0; z-index:9999; background:rgba(15,23,42,.82); display:flex; align-items:center; justify-content:center; padding:24px; }
.assistant-image-modal-card { position:relative; max-width:min(96vw,1200px); max-height:92vh; } .assistant-image-modal-card { position:relative; display:grid; gap:10px; max-width:min(96vw,1200px); max-height:92vh; }
.assistant-image-modal-card img { max-width:100%; max-height:92vh; border-radius:14px; background:white; box-shadow:0 24px 80px rgba(0,0,0,.35); } .assistant-image-modal-card img { max-width:100%; max-height:92vh; border-radius:14px; background:white; box-shadow:0 24px 80px rgba(0,0,0,.35); }
.assistant-image-modal-close { position:absolute; top:-12px; right:-12px; width:34px; height:34px; border:0; border-radius:999px; background:white; color:var(--g800); font-size:22px; line-height:1; cursor:pointer; box-shadow:var(--shadow); } .assistant-image-modal-close { position:absolute; top:8px; right:8px; z-index:1; width:38px; height:38px; border:0; border-radius:999px; background:white; color:var(--g800); font-size:24px; line-height:1; cursor:pointer; box-shadow:var(--shadow); }
.assistant-image-modal-cancel { justify-self:center; border:0; border-radius:999px; background:white; color:var(--g800); font-weight:700; padding:9px 14px; box-shadow:var(--shadow); cursor:pointer; }
.assistant-sources { padding:10px 12px; display:grid; gap:8px; max-height:520px; overflow-y:auto; } .assistant-sources { padding:10px 12px; display:grid; gap:8px; max-height:520px; overflow-y:auto; }
.assistant-saved-chats { padding:10px 12px; display:grid; gap:8px; max-height:220px; overflow-y:auto; }
.assistant-saved-chat { border:1px solid var(--g200); border-radius:10px; padding:8px; background:white; display:grid; gap:5px; }
.assistant-saved-chat-title { font-size:12px; font-weight:700; color:var(--g800); line-height:1.35; }
.assistant-saved-chat-meta { font-size:11px; color:var(--g500); }
.assistant-saved-chat-actions { display:flex; gap:6px; flex-wrap:wrap; }
.assistant-save-panel { border-top:1px solid var(--g200); padding:10px 12px; display:grid; gap:7px; }
.assistant-save-panel[hidden] { display:none; }
.assistant-save-panel label { font-size:11px; font-weight:700; color:var(--g500); text-transform:uppercase; letter-spacing:.04em; }
.assistant-save-panel input { width:100%; border:1.5px solid var(--g300); border-radius:9px; padding:8px 10px; font-size:12px; outline:none; }
.assistant-save-panel input:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); }
.assistant-save-actions { display:flex; gap:6px; flex-wrap:wrap; }
.assistant-source { border:1px solid var(--g200); border-radius:10px; padding:9px; background:white; font-size:12px; line-height:1.5; } .assistant-source { border:1px solid var(--g200); border-radius:10px; padding:9px; background:white; font-size:12px; line-height:1.5; }
.assistant-source strong { color:var(--g800); } .assistant-source strong { color:var(--g800); }
.assistant-source-badges { display:flex; gap:5px; flex-wrap:wrap; margin-top:6px; }
.assistant-source-badges span { border:1px solid var(--g200); border-radius:999px; background:var(--g50); color:var(--g600); padding:2px 7px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; }
.assistant-source-meta { color:var(--g500); font-size:11px; margin-top:3px; } .assistant-source-meta { color:var(--g500); font-size:11px; margin-top:3px; }
.assistant-source-preview { margin-top:8px; }
.assistant-source-preview button { border:0; padding:0; background:transparent; cursor:pointer; width:100%; display:block; }
.assistant-source-preview img { width:100%; max-height:220px; object-fit:contain; border:1px solid var(--g200); border-radius:10px; background:white; display:block; }
.assistant-source-excerpt { margin-top:7px; color:var(--g600); max-height:170px; overflow:auto; } .assistant-source-excerpt { margin-top:7px; color:var(--g600); max-height:170px; overflow:auto; }
.assistant-source-excerpt p { margin:0 0 6px; } .assistant-source-excerpt p { margin:0 0 6px; }
.assistant-source-excerpt ul, .assistant-source-excerpt ol { padding-left:16px; margin:4px 0; } .assistant-source-excerpt ul, .assistant-source-excerpt ol { padding-left:16px; margin:4px 0; }
@ -142,4 +188,20 @@
.assistant-muted { color:var(--g500); font-size:12px; line-height:1.6; } .assistant-muted { color:var(--g500); font-size:12px; line-height:1.6; }
.assistant-mermaid { background:white; border:1px solid var(--g200); border-radius:10px; padding:10px; margin:10px 0; overflow:auto; } .assistant-mermaid { background:white; border:1px solid var(--g200); border-radius:10px; padding:10px; margin:10px 0; overflow:auto; }
@media (max-width: 960px) { .assistant-layout { grid-template-columns:1fr; } .assistant-main { min-height:auto; grid-template-rows:auto minmax(320px,1fr) auto; } } @media (max-width: 960px) { .assistant-layout { grid-template-columns:1fr; } .assistant-main { min-height:auto; grid-template-rows:auto minmax(320px,1fr) auto; } }
@media (max-width: 640px) {
.assistant-header { flex-direction:column; align-items:stretch; }
.assistant-status { align-self:flex-start; }
.assistant-toolbar { flex-direction:column; align-items:stretch; }
.assistant-toolbar-actions { display:grid; grid-template-columns:1fr 1fr; }
.assistant-toolbar-actions .btn-sm { width:100%; justify-content:center; }
.assistant-messages { padding:10px; }
.assistant-msg, .assistant-msg.user { max-width:100%; }
.assistant-bubble { font-size:13px; padding:11px 12px; }
.assistant-table-scroll::after { display:block; }
.assistant-composer { position:sticky; bottom:0; z-index:3; }
.assistant-composer-footer { flex-direction:column; align-items:stretch; }
.assistant-composer-footer .btn-generate { width:100%; }
.assistant-composer-footer #btn-assistant-cancel:not([hidden]) { width:100%; justify-content:center; }
.assistant-side { gap:10px; }
}
</style> </style>

View file

@ -0,0 +1,235 @@
<div class="module-header">
<h2><i class="fas fa-diagram-project" style="color:#0ea5e9;"></i> Diagrams</h2>
</div>
<div class="diagrams-layout" id="diagrams-layout">
<aside class="diagrams-sidebar">
<div class="diagrams-sidebar-head">
<button id="btn-diagram-new" class="btn-primary diagrams-new-btn" type="button">
<i class="fas fa-plus"></i> New diagram
</button>
<div class="diagrams-search">
<i class="fas fa-search"></i>
<input type="text" id="diagram-search" placeholder="Search" autocomplete="off">
</div>
</div>
<div id="diagrams-list" class="diagrams-list">
<div class="diagrams-empty">Loading…</div>
</div>
</aside>
<section id="diagram-editor" class="diagram-editor">
<div class="diagram-toolbar">
<input type="text" id="diagram-title" class="diagram-title-input" placeholder="Diagram title" autocomplete="off">
<span id="diagram-status" class="diagram-status"></span>
<div class="diagram-toolbar-actions">
<button id="btn-diagram-export-svg" class="btn-sm" type="button" title="Export SVG">
<i class="fas fa-download"></i> SVG
</button>
<button id="btn-diagram-export-png" class="btn-sm" type="button" title="Export PNG">
<i class="fas fa-download"></i> PNG
</button>
<button id="btn-diagram-delete" class="btn-sm btn-diagram-delete" type="button" title="Delete diagram"
style="background:var(--red-light);color:var(--red);border:1px solid var(--red);">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<div class="diagram-panes">
<div class="diagram-source-pane">
<textarea id="diagram-source" class="diagram-source" spellcheck="false"
placeholder="graph TD&#10; A[Start] --> B{Decision?}&#10; B -->|Yes| C[Action]&#10; B -->|No| D[End]"></textarea>
</div>
<div class="diagram-preview-pane">
<div id="diagram-preview" class="diagram-preview"></div>
<div id="diagram-error" class="diagram-error hidden"></div>
</div>
</div>
</section>
</div>
<style>
.diagrams-layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 16px;
height: calc(100vh - 200px);
min-height: 480px;
}
.diagrams-sidebar {
display: flex;
flex-direction: column;
background: var(--g50, #f8fafb);
border: 1px solid var(--g100, #e5ebef);
border-radius: 8px;
overflow: hidden;
}
.diagrams-sidebar-head {
padding: 10px;
display: grid;
gap: 8px;
border-bottom: 1px solid var(--g100, #e5ebef);
background: white;
}
.diagrams-new-btn { width: 100%; }
.diagrams-search {
position: relative;
}
.diagrams-search i {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--g400, #94a3b8);
font-size: 12px;
}
.diagrams-search input {
width: 100%;
padding: 7px 10px 7px 28px;
border: 1px solid var(--g200, #d5dfe6);
border-radius: 6px;
font-size: 13px;
}
.diagrams-list {
flex: 1;
overflow-y: auto;
padding: 6px;
}
.diagrams-empty {
padding: 18px 12px;
color: var(--g400, #94a3b8);
font-size: 13px;
text-align: center;
}
.diagram-row {
display: grid;
gap: 2px;
padding: 9px 10px;
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
}
.diagram-row:hover {
background: white;
border-color: var(--g200, #d5dfe6);
}
.diagram-row.active {
background: var(--blue-light, #e0f2fe);
border-color: var(--blue, #0ea5e9);
}
.diagram-row strong {
font-size: 13.5px;
color: var(--g800, #1e293b);
}
.diagram-row span {
font-size: 11px;
color: var(--g400, #94a3b8);
}
.diagram-editor {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
background: white;
border: 1px solid var(--g100, #e5ebef);
border-radius: 8px;
overflow: hidden;
}
.diagram-toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-bottom: 1px solid var(--g100, #e5ebef);
background: var(--g50, #f8fafb);
}
.diagram-title-input {
flex: 1;
min-width: 0;
padding: 7px 10px;
border: 1px solid var(--g200, #d5dfe6);
border-radius: 6px;
font-size: 14px;
font-weight: 600;
background: white;
}
.diagram-status {
font-size: 11.5px;
color: var(--g400, #94a3b8);
white-space: nowrap;
}
.diagram-status.saving { color: var(--amber, #d97706); }
.diagram-status.saved { color: var(--green, #16a34a); }
.diagram-status.error { color: var(--red, #dc2626); }
.diagram-toolbar-actions {
display: flex;
gap: 6px;
}
.diagram-panes {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
}
.diagram-source-pane {
border-right: 1px solid var(--g100, #e5ebef);
display: flex;
}
.diagram-source {
flex: 1;
width: 100%;
height: 100%;
border: 0;
padding: 12px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.55;
resize: none;
outline: none;
background: white;
color: var(--g800, #1e293b);
}
.diagram-preview-pane {
position: relative;
overflow: auto;
padding: 18px;
background: var(--g50, #f8fafb);
}
.diagram-preview {
display: flex;
justify-content: center;
align-items: flex-start;
}
.diagram-preview svg {
max-width: 100%;
height: auto;
}
.diagram-error {
margin-top: 10px;
padding: 10px 12px;
background: var(--red-light, #fef2f2);
border: 1px solid var(--red, #dc2626);
color: var(--red, #dc2626);
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 12px;
border-radius: 6px;
white-space: pre-wrap;
}
@media (max-width: 900px) {
.diagrams-layout {
grid-template-columns: 1fr;
height: auto;
}
.diagrams-sidebar {
max-height: 280px;
}
.diagram-panes {
grid-template-columns: 1fr;
grid-template-rows: 320px minmax(280px, 1fr);
}
.diagram-source-pane {
border-right: 0;
border-bottom: 1px solid var(--g100, #e5ebef);
}
}
</style>

View file

@ -11,9 +11,26 @@
style="width:100%;padding:8px 10px 8px 32px;font-size:14px;border:1px solid var(--g300);border-radius:8px;"> style="width:100%;padding:8px 10px 8px 32px;font-size:14px;border:1px solid var(--g300);border-radius:8px;">
</div> </div>
<button id="ext-add-btn" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add</button> <button id="ext-add-btn" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add</button>
<button id="ext-export-btn" class="btn-sm btn-ghost"><i class="fas fa-file-export"></i> Export</button>
<button id="ext-import-btn" class="btn-sm btn-ghost"><i class="fas fa-file-import"></i> Import</button>
<input type="file" id="ext-import-file" accept="application/zip,application/json,.zip,.json" class="hidden">
<button id="ext-trash-btn" class="btn-sm btn-ghost"><i class="fas fa-trash-can"></i> Trash <span id="ext-trash-count" style="color:var(--g500);font-size:11px;"></span></button> <button id="ext-trash-btn" class="btn-sm btn-ghost"><i class="fas fa-trash-can"></i> Trash <span id="ext-trash-count" style="color:var(--g500);font-size:11px;"></span></button>
</div> </div>
<div id="ext-import-preview" class="hidden" style="margin:0 16px 14px;padding:12px;border:1px solid var(--g200);border-radius:10px;background:var(--g50);">
<div id="ext-import-preview-text" style="font-size:13px;color:var(--g700);line-height:1.5;margin-bottom:10px;"></div>
<label style="display:flex;gap:8px;align-items:center;font-size:12px;color:var(--g700);margin-bottom:6px;">
<input type="checkbox" id="ext-import-restore-trashed"> Restore exact matches currently in trash
</label>
<label style="display:flex;gap:8px;align-items:center;font-size:12px;color:var(--g700);margin-bottom:10px;">
<input type="checkbox" id="ext-import-possible"> Import possible duplicates instead of skipping them
</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button id="ext-import-confirm" class="btn-sm btn-primary"><i class="fas fa-file-import"></i> Import Selected</button>
<button id="ext-import-cancel" class="btn-sm btn-ghost">Cancel</button>
</div>
</div>
<div id="ext-form-wrap" class="hidden" style="border-top:1px solid var(--g100);padding:14px 16px;background:var(--g50);"> <div id="ext-form-wrap" class="hidden" style="border-top:1px solid var(--g100);padding:14px 16px;background:var(--g50);">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;"> <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;">
<div class="demo-field"> <div class="demo-field">

View file

@ -101,14 +101,6 @@
</div> </div>
</div> </div>
<div class="faq-item">
<button class="faq-question">What is Browser Whisper?</button>
<div class="faq-answer">
<p>Browser Whisper runs the Whisper AI model entirely in your browser using WebAssembly. Your audio never leaves your device, making it the most private transcription option. You can enable it in <strong>Settings &gt; Browser Whisper</strong>.</p>
<p>It works offline and is HIPAA-safe since no data is transmitted. The tradeoff is that it is slower than cloud-based transcription and requires downloading the model (~40&ndash;240 MB) on first use.</p>
</div>
</div>
<div class="faq-item"> <div class="faq-item">
<button class="faq-question">Can I use the app on my phone?</button> <button class="faq-question">Can I use the app on my phone?</button>
<div class="faq-answer"> <div class="faq-answer">
@ -176,7 +168,7 @@
<li>No patient data is stored long-term on the server</li> <li>No patient data is stored long-term on the server</li>
<li>Every action is audit-logged (who accessed what, when)</li> <li>Every action is audit-logged (who accessed what, when)</li>
<li>Two-factor authentication (2FA) and session management are available</li> <li>Two-factor authentication (2FA) and session management are available</li>
<li>Browser Whisper keeps audio entirely on your device</li> <li>Server transcription can be configured with HIPAA-eligible providers</li>
</ul> </ul>
<p>For HIPAA compliance, ensure your administrator has configured a BAA-covered AI provider (such as AWS Bedrock, Google Vertex AI, or Azure OpenAI).</p> <p>For HIPAA compliance, ensure your administrator has configured a BAA-covered AI provider (such as AWS Bedrock, Google Vertex AI, or Azure OpenAI).</p>
</div> </div>
@ -343,4 +335,3 @@
</div> </div>
</div> </div>

View file

@ -34,33 +34,6 @@
</div> </div>
</div> </div>
<!-- Browser Whisper -->
<div class="settings-section card" id="browser-whisper-section">
<h3><i class="fas fa-microchip"></i> Browser Transcription (Local Whisper)</h3>
<p style="font-size:13px;color:var(--g600);">Transcribes audio entirely in your browser — no audio sent to any server. Powered by OpenAI Whisper running in WebAssembly. Model is downloaded once and cached locally.</p>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
<label style="font-size:13px;font-weight:600;">Enable browser transcription:</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" id="browser-whisper-enabled" style="accent-color:var(--blue);width:16px;height:16px;">
<span style="font-size:13px;" id="browser-whisper-status">Off</span>
</label>
</div>
<div id="browser-whisper-model-row" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
<label style="font-size:13px;font-weight:600;">Model:</label>
<select id="browser-whisper-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;">
<option value="Xenova/whisper-tiny.en">Tiny (~39MB) — fastest, ~2-3s</option>
<option value="Xenova/whisper-base.en">Base (~74MB) — balanced, ~3-5s</option>
<option value="Xenova/whisper-small.en">Small (~244MB) — best quality, ~6-10s</option>
</select>
<button id="btn-whisper-preload" class="btn-sm btn-ghost"><i class="fas fa-download"></i> Pre-download model</button>
</div>
<div id="browser-whisper-progress" style="display:none;font-size:12px;color:var(--g500);margin-top:4px;">
<i class="fas fa-spinner fa-spin"></i> <span id="browser-whisper-progress-text">Loading...</span>
</div>
<p style="font-size:12px;color:var(--g400);margin:8px 0 0;"><i class="fas fa-info-circle"></i> When enabled, overrides server transcription. Falls back to server if browser transcription fails.</p>
<p style="font-size:11px;color:var(--orange);margin:4px 0 0;display:none;" id="browser-whisper-csp-warning"><i class="fas fa-exclamation-triangle"></i> <strong>Network/Firewall Issue:</strong> If model download fails, check that <code>cdn.jsdelivr.net</code> and <code>huggingface.co</code> are not blocked. Server transcription will be used as fallback.</p>
</div>
<!-- Web Speech Recognition (Real-time Streaming) --> <!-- Web Speech Recognition (Real-time Streaming) -->
<div class="settings-section card" id="web-speech-section" style="border-left:3px solid var(--orange);"> <div class="settings-section card" id="web-speech-section" style="border-left:3px solid var(--orange);">
<h3><i class="fas fa-wave-square"></i> Real-Time Streaming Transcription</h3> <h3><i class="fas fa-wave-square"></i> Real-Time Streaming Transcription</h3>
@ -82,7 +55,7 @@
<p style="margin:0;" id="web-speech-browser-info">Detecting...</p> <p style="margin:0;" id="web-speech-browser-info">Detecting...</p>
</div> </div>
<p style="font-size:11px;color:var(--g400);margin:8px 0 0;"><i class="fas fa-info-circle"></i> <strong>Trade-off:</strong> Immediate transcription vs. privacy. For maximum privacy, use Browser Whisper (offline batch mode) or Server transcription with HIPAA-eligible provider.</p> <p style="font-size:11px;color:var(--g400);margin:8px 0 0;"><i class="fas fa-info-circle"></i> <strong>Trade-off:</strong> Immediate transcription vs. privacy. For maximum privacy, use server transcription with a HIPAA-eligible provider.</p>
</div> </div>
<!-- Change Password — hidden by default; unhidden only for users with a real password --> <!-- Change Password — hidden by default; unhidden only for users with a real password -->
@ -178,7 +151,7 @@
<!-- My Templates / Memories --> <!-- My Templates / Memories -->
<div class="settings-section card"> <div class="settings-section card">
<h3><i class="fas fa-book-medical"></i> My Templates</h3> <h3><i class="fas fa-book-medical"></i> My Templates</h3>
<p style="font-size:13px;color:var(--g600);">Save reusable templates for physical exam, ROS, encounter format, etc. The AI will use these when generating notes. You can reference them by saying "use my normal physical exam" in dictation.</p> <p style="font-size:13px;color:var(--g600);">Save reusable templates for physical exam, ROS, encounter format, etc. Only template categories are sent to AI when generating notes. You can reference them by saying "use my normal physical exam" in dictation.</p>
<div style="margin-bottom:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;"> <div style="margin-bottom:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<select id="mem-category" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;"> <select id="mem-category" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;">
<option value="physical_exam">Physical Exam Template</option> <option value="physical_exam">Physical Exam Template</option>
@ -191,9 +164,9 @@
<option value="template_wellvisit">Well Visit Template</option> <option value="template_wellvisit">Well Visit Template</option>
<option value="template_sickvisit">Sick Visit Template</option> <option value="template_sickvisit">Sick Visit Template</option>
<option value="template_ed">ED Template</option> <option value="template_ed">ED Template</option>
<option value="custom">Custom</option>
</select> </select>
<input type="text" id="mem-name" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:150px;" placeholder="Template name (e.g. Normal PE)"> <input type="text" id="mem-name" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:150px;" placeholder="Template name (e.g. Normal PE)">
<a href="/template-guide.md" download="ped-ai-template-guide.md" class="btn-sm btn-ghost" style="text-decoration:none;"><i class="fas fa-download"></i> Template Guide</a>
</div> </div>
<textarea id="mem-content" rows="5" style="width:100%;font-size:12px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Paste your template here. Example: HEENT: Normocephalic, atraumatic. Eyes: PERRL. Ears: TMs clear. Throat: clear..."></textarea> <textarea id="mem-content" rows="5" style="width:100%;font-size:12px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Paste your template here. Example: HEENT: Normocephalic, atraumatic. Eyes: PERRL. Ears: TMs clear. Throat: clear..."></textarea>
<div style="margin-top:8px;display:flex;gap:8px;"> <div style="margin-top:8px;display:flex;gap:8px;">
@ -242,14 +215,6 @@
<div class="settings-section card"> <div class="settings-section card">
<h3><i class="fas fa-shield-halved"></i> Compliance & Usage</h3> <h3><i class="fas fa-shield-halved"></i> Compliance & Usage</h3>
<div class="hipaa-info"> <div class="hipaa-info">
<p><strong>AWS Bedrock</strong> is available with a Business Associate Agreement (BAA) for HIPAA-eligible workloads.</p>
<ul>
<li>✅ All connections use HTTPS/TLS encryption</li>
<li>✅ Authentication with optional 2FA</li>
<li>✅ No patient data stored on server beyond session</li>
<li>✅ AWS Bedrock supports BAA for HIPAA compliance</li>
<li>✅ Azure OpenAI supports BAA for HIPAA compliance</li>
</ul>
<p><strong>Important:</strong> Check with your institution's guidelines and policies before use. This tool is not intended for production clinical use without proper organizational authorization and provider BAAs in place. Use with caution.</p> <p><strong>Important:</strong> Check with your institution's guidelines and policies before use. This tool is not intended for production clinical use without proper organizational authorization and provider BAAs in place. Use with caution.</p>
</div> </div>
</div> </div>

View file

@ -11,6 +11,9 @@
<button class="wv-subtab-btn" data-subtab="milestones"> <button class="wv-subtab-btn" data-subtab="milestones">
<i class="fas fa-baby"></i> Milestones <i class="fas fa-baby"></i> Milestones
</button> </button>
<button class="wv-subtab-btn" data-subtab="lincoln">
<i class="fas fa-clipboard-list"></i> Lincoln
</button>
<button class="wv-subtab-btn" data-subtab="shadess" style="display:none;"> <button class="wv-subtab-btn" data-subtab="shadess" style="display:none;">
<i class="fas fa-brain"></i> SSHADESS (12+) <i class="fas fa-brain"></i> SSHADESS (12+)
</button> </button>
@ -119,6 +122,65 @@
</div> </div>
<!-- Lincoln quick-reference sub-panel -->
<div id="wv-panel-lincoln" class="wv-subpanel hidden">
<div class="card" style="margin-bottom:10px;">
<div class="card-header output-header">
<h3><i class="fas fa-clipboard-list"></i> Lincoln Well-Child Quick Reference</h3>
<div class="output-actions">
<button class="btn-sm btn-primary" data-action="copy" data-target="wv-lincoln-reference"><i class="fas fa-copy"></i> Copy</button>
</div>
</div>
<div id="wv-lincoln-reference" class="wv-lincoln-reference">
<div class="wv-lincoln-grid">
<section class="wv-lincoln-card">
<h4>Infancy</h4>
<ul>
<li><strong>Newborn:</strong> POC visit; check for jaundice.</li>
<li><strong>2 weeks:</strong> weight gain, newborn screen, umbilicus check.</li>
<li><strong>1 month:</strong> maternal PHQ-9.</li>
<li><strong>2 months:</strong> Vaxelis, rotavirus, Prevnar.</li>
<li><strong>4 months:</strong> Vaxelis, rotavirus, Prevnar.</li>
<li><strong>6 months:</strong> routine vaccines and Prevnar; confirm rotavirus eligibility by product and age.</li>
<li><strong>9 months:</strong> SWYC; no routine vaccines noted.</li>
</ul>
</section>
<section class="wv-lincoln-card">
<h4>Toddler / Preschool</h4>
<ul>
<li><strong>12 months:</strong> MMR, varicella, Hep A; CBC and lead.</li>
<li><strong>15 months:</strong> Pentacel, Prevnar, influenza.</li>
<li><strong>18 months:</strong> POSI, SWYC; Hep A second dose.</li>
<li><strong>2 years:</strong> POSI/SWYC; CBC and lead.</li>
<li><strong>3 years:</strong> blood pressure check and vision screening; BP is commonly missed and can be added on diagnosis.</li>
<li><strong>4 years:</strong> hearing and vision start; ProQuad and Kinrix.</li>
</ul>
</section>
<section class="wv-lincoln-card">
<h4>School Age / Adolescence</h4>
<ul>
<li><strong>Lipid screening:</strong> AAP screening at 9-11 years and 17-21 years.</li>
<li><strong>Depression screening:</strong> begin at 12 years and older.</li>
<li><strong>MenB:</strong> discuss Bexsero/MenB at 16-23 years, preferably 16-18 years, when chosen or indicated.</li>
<li><strong>Age &ge;18 years:</strong> Hep C testing.</li>
<li><strong>Cervical cancer screening:</strong> start Pap smear screening at 21 years.</li>
</ul>
</section>
<section class="wv-lincoln-card">
<h4>Catch-Up / Screening Reminders</h4>
<ul>
<li><strong>Influenza:</strong> if a child 6 months through 8 years needs 2 doses, give doses 4 weeks apart.</li>
<li><strong>Lead:</strong> continue lead screening reminders through age 6 years; add diagnosis when needed.</li>
</ul>
</section>
</div>
</div>
</div>
</div>
<!-- SSHADESS sub-panel (age 12+) --> <!-- SSHADESS sub-panel (age 12+) -->
<div id="wv-panel-shadess" class="wv-subpanel hidden"> <div id="wv-panel-shadess" class="wv-subpanel hidden">
<div class="card" style="margin-bottom:10px;"> <div class="card" style="margin-bottom:10px;">
@ -305,4 +367,3 @@
</div> </div>
</div> </div>
</div> </div>

View file

@ -143,6 +143,7 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
.btn-generate-green{background:var(--green);box-shadow:0 3px 10px rgba(16,185,129,0.25);} .btn-generate-green{background:var(--green);box-shadow:0 3px 10px rgba(16,185,129,0.25);}
.btn-sm{display:inline-flex;align-items:center;gap:4px;padding:5px 10px;border:none;border-radius:6px;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;transition:all 0.15s;} .btn-sm{display:inline-flex;align-items:center;gap:4px;padding:5px 10px;border:none;border-radius:6px;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;transition:all 0.15s;}
#btn-assistant-cancel[hidden]{display:none!important;}
.btn-lg{display:inline-flex;align-items:center;gap:6px;padding:9px 18px;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s;} .btn-lg{display:inline-flex;align-items:center;gap:6px;padding:9px 18px;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s;}
.btn-primary{background:var(--blue);color:white;}.btn-primary:hover{background:var(--blue-dark);} .btn-primary{background:var(--blue);color:white;}.btn-primary:hover{background:var(--blue-dark);}
.btn-ghost{background:var(--g200);color:var(--g700);}.btn-ghost:hover{background:var(--g300);} .btn-ghost{background:var(--g200);color:var(--g700);}.btn-ghost:hover{background:var(--g300);}
@ -396,6 +397,21 @@ textarea.full-input{resize:vertical;}
.wv-section-title{font-size:14px;font-weight:700;color:var(--g700);margin-bottom:12px;display:flex;align-items:center;gap:8px;} .wv-section-title{font-size:14px;font-weight:700;color:var(--g700);margin-bottom:12px;display:flex;align-items:center;gap:8px;}
.wv-section-title i{color:var(--blue);} .wv-section-title i{color:var(--blue);}
/* Lincoln quick reference */
.wv-lincoln-reference{padding:14px 16px;background:var(--g50);}
.wv-lincoln-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px;}
.wv-lincoln-card{border:1px solid var(--g100);border-radius:12px;padding:14px 16px;background:white;box-shadow:0 1px 2px rgba(15,23,42,0.04);}
.wv-lincoln-card h4{margin:0 0 10px;font-size:14px;color:var(--g800);}
.wv-lincoln-card ul{margin:0;padding-left:18px;color:var(--g700);font-size:13px;line-height:1.65;}
.wv-lincoln-card li{margin-bottom:6px;}
.wv-lincoln-card li:last-child{margin-bottom:0;}
@media(max-width:640px){
.wv-lincoln-reference{padding:10px;}
.wv-lincoln-grid{grid-template-columns:1fr;gap:10px;}
.wv-lincoln-card{padding:12px;}
.wv-lincoln-card ul{font-size:12.5px;line-height:1.55;}
}
/* Billing */ /* Billing */
.wv-billing-grid{display:flex;flex-wrap:wrap;gap:12px;align-items:center;} .wv-billing-grid{display:flex;flex-wrap:wrap;gap:12px;align-items:center;}
.wv-billing-cell{display:flex;align-items:center;gap:8px;} .wv-billing-cell{display:flex;align-items:center;gap:8px;}
@ -626,7 +642,7 @@ textarea.full-input{resize:vertical;}
.lh-quiz-q-type{font-size:11px;color:var(--g400);background:var(--g100);padding:2px 8px;border-radius:4px;} .lh-quiz-q-type{font-size:11px;color:var(--g400);background:var(--g100);padding:2px 8px;border-radius:4px;}
.lh-quiz-q-text{font-size:16px;font-weight:600;margin-bottom:14px;color:var(--g800);line-height:1.5;} .lh-quiz-q-text{font-size:16px;font-weight:600;margin-bottom:14px;color:var(--g800);line-height:1.5;}
.lh-quiz-options{display:flex;flex-direction:column;gap:8px;} .lh-quiz-options{display:flex;flex-direction:column;gap:8px;}
.lh-quiz-option{display:flex;align-items:center;gap:12px;padding:14px 18px;border:2px solid var(--g200);border-radius:10px;cursor:pointer;transition:all 0.2s;font-size:14px;line-height:1.4;background:white;} .lh-quiz-option{display:flex;align-items:center;gap:12px;padding:14px 18px;border:2px solid var(--g200);border-radius:10px;cursor:pointer;transition:all 0.2s;font-size:14px;line-height:1.4;background:white;user-select:none;}
.lh-quiz-option:hover{border-color:var(--blue);background:var(--blue-light);transform:translateY(-1px);box-shadow:0 2px 8px rgba(37,99,235,0.1);} .lh-quiz-option:hover{border-color:var(--blue);background:var(--blue-light);transform:translateY(-1px);box-shadow:0 2px 8px rgba(37,99,235,0.1);}
.lh-quiz-option input[type="radio"]{accent-color:var(--blue);width:18px;height:18px;flex-shrink:0;} .lh-quiz-option input[type="radio"]{accent-color:var(--blue);width:18px;height:18px;flex-shrink:0;}
.lh-quiz-option span{flex:1;} .lh-quiz-option span{flex:1;}
@ -1139,6 +1155,8 @@ textarea.full-input{resize:vertical;}
.docs-reader-body th{background:var(--g50);font-weight:600;} .docs-reader-body th{background:var(--g50);font-weight:600;}
.docs-reader-body a{color:var(--blue);text-decoration:none;} .docs-reader-body a{color:var(--blue);text-decoration:none;}
.docs-reader-body a:hover{text-decoration:underline;} .docs-reader-body a:hover{text-decoration:underline;}
.docs-reader-body .docs-anchor-link{color:var(--blue);cursor:pointer;text-decoration:none;}
.docs-reader-body .docs-anchor-link:hover{text-decoration:underline;}
.docs-reader-body hr{border:0;border-top:1px solid var(--g200);margin:1.6em 0;} .docs-reader-body hr{border:0;border-top:1px solid var(--g200);margin:1.6em 0;}
@media (max-width:900px){ @media (max-width:900px){

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
{
"version": "1.0",
"source": "Bhutani VK et al., Pediatrics 1999;103(1):6-14; cross-checked against AAP 2004 CPG reproduction",
"zones": {
"p95": { "6": 6.0, "12": 7.2, "18": 8.5, "24": 9.6, "30": 11.2, "36": 12.8, "42": 13.8, "48": 14.8, "54": 15.6, "60": 16.2, "66": 16.8, "72": 17.4, "84": 18.0, "96": 18.4, "108": 18.8, "120": 19.0 },
"p75": { "6": 4.5, "12": 5.5, "18": 6.6, "24": 7.8, "30": 9.2, "36": 10.6, "42": 11.6, "48": 12.6, "54": 13.4, "60": 14.0, "66": 14.6, "72": 15.0, "84": 15.4, "96": 15.6, "108": 15.8, "120": 16.0 },
"p40": { "6": 3.0, "12": 4.0, "18": 5.0, "24": 6.2, "30": 7.2, "36": 8.4, "42": 9.2, "48": 10.0, "54": 10.6, "60": 11.2, "66": 11.8, "72": 12.2, "84": 12.6, "96": 12.8, "108": 13.0, "120": 13.2 }
}
}

View file

@ -0,0 +1,380 @@
{
"version": "1.0",
"source": "CDC 2000 BMI-for-age LMS values, 24-240 months every 6 months as previously embedded in calculators.js",
"lms": {
"male": {
"24": {
"L": -1.982374,
"M": 16.5478,
"S": 0.080127
},
"30": {
"L": -1.642107,
"M": 16.2497,
"S": 0.075499
},
"36": {
"L": -1.419991,
"M": 16.0003,
"S": 0.072634
},
"42": {
"L": -1.438165,
"M": 15.7941,
"S": 0.071495
},
"48": {
"L": -1.714869,
"M": 15.6282,
"S": 0.071889
},
"54": {
"L": -2.155348,
"M": 15.5026,
"S": 0.073491
},
"60": {
"L": -2.615166,
"M": 15.4191,
"S": 0.075992
},
"66": {
"L": -2.981797,
"M": 15.3795,
"S": 0.079211
},
"72": {
"L": -3.211705,
"M": 15.3835,
"S": 0.083048
},
"78": {
"L": -3.314769,
"M": 15.429,
"S": 0.0874
},
"84": {
"L": -3.323189,
"M": 15.5129,
"S": 0.092131
},
"90": {
"L": -3.270455,
"M": 15.6317,
"S": 0.097082
},
"96": {
"L": -3.183058,
"M": 15.7823,
"S": 0.102091
},
"102": {
"L": -3.079383,
"M": 15.9617,
"S": 0.107013
},
"108": {
"L": -2.971148,
"M": 16.1671,
"S": 0.111721
},
"114": {
"L": -2.865311,
"M": 16.3961,
"S": 0.116113
},
"120": {
"L": -2.765648,
"M": 16.6461,
"S": 0.120112
},
"126": {
"L": -2.673903,
"M": 16.9151,
"S": 0.123664
},
"132": {
"L": -2.59056,
"M": 17.2009,
"S": 0.126735
},
"138": {
"L": -2.51532,
"M": 17.5014,
"S": 0.129309
},
"144": {
"L": -2.447426,
"M": 17.8146,
"S": 0.131389
},
"150": {
"L": -2.385858,
"M": 18.1387,
"S": 0.132991
},
"156": {
"L": -2.329457,
"M": 18.4718,
"S": 0.134141
},
"162": {
"L": -2.277017,
"M": 18.812,
"S": 0.13488
},
"168": {
"L": -2.227362,
"M": 19.1576,
"S": 0.135251
},
"174": {
"L": -2.179426,
"M": 19.5067,
"S": 0.135309
},
"180": {
"L": -2.132345,
"M": 19.8577,
"S": 0.13511
},
"186": {
"L": -2.085574,
"M": 20.2086,
"S": 0.134718
},
"192": {
"L": -2.039015,
"M": 20.5576,
"S": 0.134198
},
"198": {
"L": -1.99315,
"M": 20.9029,
"S": 0.13362
},
"204": {
"L": -1.949135,
"M": 21.2425,
"S": 0.133057
},
"210": {
"L": -1.908831,
"M": 21.5742,
"S": 0.132585
},
"216": {
"L": -1.87467,
"M": 21.8959,
"S": 0.132286
},
"222": {
"L": -1.849323,
"M": 22.2054,
"S": 0.132249
},
"228": {
"L": -1.835138,
"M": 22.5007,
"S": 0.132566
},
"234": {
"L": -1.833401,
"M": 22.7799,
"S": 0.133339
},
"240": {
"L": -1.843581,
"M": 23.0414,
"S": 0.134675
}
},
"female": {
"24": {
"L": -1.024497,
"M": 16.388,
"S": 0.085026
},
"30": {
"L": -1.534542,
"M": 16.0059,
"S": 0.080932
},
"36": {
"L": -2.096829,
"M": 15.6992,
"S": 0.078605
},
"42": {
"L": -2.618733,
"M": 15.4647,
"S": 0.077904
},
"48": {
"L": -3.018522,
"M": 15.2985,
"S": 0.078713
},
"54": {
"L": -3.2593,
"M": 15.1961,
"S": 0.080904
},
"60": {
"L": -3.350078,
"M": 15.1519,
"S": 0.0843
},
"66": {
"L": -3.325522,
"M": 15.1606,
"S": 0.08868
},
"72": {
"L": -3.225607,
"M": 15.2169,
"S": 0.093803
},
"78": {
"L": -3.084291,
"M": 15.3161,
"S": 0.099427
},
"84": {
"L": -2.926187,
"M": 15.4536,
"S": 0.105325
},
"90": {
"L": -2.76731,
"M": 15.6252,
"S": 0.111295
},
"96": {
"L": -2.617192,
"M": 15.827,
"S": 0.117159
},
"102": {
"L": -2.480952,
"M": 16.0552,
"S": 0.122771
},
"108": {
"L": -2.360921,
"M": 16.3061,
"S": 0.128014
},
"114": {
"L": -2.257782,
"M": 16.5763,
"S": 0.132797
},
"120": {
"L": -2.171296,
"M": 16.8623,
"S": 0.137057
},
"126": {
"L": -2.100749,
"M": 17.161,
"S": 0.140754
},
"132": {
"L": -2.045235,
"M": 17.4691,
"S": 0.143868
},
"138": {
"L": -2.003802,
"M": 17.7836,
"S": 0.146399
},
"144": {
"L": -1.975521,
"M": 18.1015,
"S": 0.148361
},
"150": {
"L": -1.95952,
"M": 18.42,
"S": 0.149783
},
"156": {
"L": -1.954978,
"M": 18.7364,
"S": 0.150705
},
"162": {
"L": -1.9611,
"M": 19.0481,
"S": 0.151176
},
"168": {
"L": -1.977074,
"M": 19.3526,
"S": 0.151256
},
"174": {
"L": -2.002014,
"M": 19.6475,
"S": 0.15101
},
"180": {
"L": -2.034893,
"M": 19.9306,
"S": 0.150512
},
"186": {
"L": -2.07446,
"M": 20.1998,
"S": 0.149843
},
"192": {
"L": -2.119157,
"M": 20.4533,
"S": 0.14909
},
"198": {
"L": -2.167045,
"M": 20.6891,
"S": 0.148349
},
"204": {
"L": -2.215738,
"M": 20.9058,
"S": 0.147723
},
"210": {
"L": -2.262382,
"M": 21.1016,
"S": 0.147323
},
"216": {
"L": -2.303688,
"M": 21.2753,
"S": 0.147269
},
"222": {
"L": -2.336038,
"M": 21.4255,
"S": 0.147689
},
"228": {
"L": -2.355678,
"M": 21.5508,
"S": 0.148724
},
"234": {
"L": -2.35898,
"M": 21.6501,
"S": 0.150521
},
"240": {
"L": -2.342797,
"M": 21.7219,
"S": 0.153241
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
{
"version": "1.0",
"source": "Harriet Lane Handbook",
"ageGroups": {
"premie": { "label": "Premie (1-3 kg)", "bvm": "Infant", "nasal": "12 Fr", "oral": "Infant", "blade": "Miller 0", "ett": "2.5-3.0", "lma": "1", "glidescope": "1", "iv": "22-24 ga", "cvl": "3 Fr", "ngt": "5 Fr", "chest": "10-12 Fr", "foley": "6 Fr" },
"newborn": { "label": "Newborn (2-4 kg)", "bvm": "Infant", "nasal": "14-16 Fr", "oral": "Small 50 mm", "blade": "Miller 0", "ett": "3.0-3.5", "lma": "1", "glidescope": "1", "iv": "22-24 ga", "cvl": "3-4 Fr", "ngt": "5-8 Fr", "chest": "10-12 Fr", "foley": "6 Fr" },
"6mo": { "label": "6 months (6-8 kg)", "bvm": "Infant", "nasal": "14-16 Fr", "oral": "Small 60 mm", "blade": "Miller 1", "ett": "3.5", "lma": "1.5", "glidescope": "2", "iv": "20-24 ga", "cvl": "4 Fr", "ngt": "8 Fr", "chest": "12-18 Fr", "foley": "8 Fr" },
"1yr": { "label": "1 year (10 kg)", "bvm": "Small child", "nasal": "14-18 Fr", "oral": "Small 60 mm", "blade": "Miller 1 / MAC 2", "ett": "4.0", "lma": "2", "glidescope": "2", "iv": "20-24 ga", "cvl": "4-5 Fr", "ngt": "10 Fr", "chest": "16-20 Fr", "foley": "8 Fr" },
"2-3yr": { "label": "2-3 years (12-16 kg)", "bvm": "Small child", "nasal": "14-18 Fr", "oral": "Small 70 mm", "blade": "Miller 1 / MAC 2", "ett": "4.0-4.5", "lma": "2", "glidescope": "2", "iv": "18-22 ga", "cvl": "4-5 Fr", "ngt": "10-12 Fr", "chest": "16-24 Fr", "foley": "8 Fr" },
"4-6yr": { "label": "4-6 years (20-25 kg)", "bvm": "Child", "nasal": "16-20 Fr", "oral": "Small 70-80 mm", "blade": "Miller 2 / MAC 2", "ett": "4.5-5.0", "lma": "2.5", "glidescope": "3", "iv": "18-22 ga", "cvl": "5 Fr", "ngt": "12-14 Fr", "chest": "20-28 Fr", "foley": "8 Fr" },
"7-10yr": { "label": "7-10 years (25-35 kg)", "bvm": "Child / Small adult", "nasal": "18-22 Fr", "oral": "Medium 80-90 mm", "blade": "Miller 2 / MAC 2", "ett": "5.5-6.0", "lma": "2.5-3", "glidescope": "3", "iv": "18-22 ga", "cvl": "5 Fr", "ngt": "12-14 Fr", "chest": "20-32 Fr", "foley": "8 Fr" },
"11-15yr": { "label": "11-15 years (40-50 kg)", "bvm": "Adult", "nasal": "22-36 Fr", "oral": "Medium 90 mm", "blade": "Miller 2 / MAC 3", "ett": "6.0-6.5", "lma": "3", "glidescope": "3 or 4", "iv": "18-20 ga", "cvl": "7 Fr", "ngt": "14-18 Fr", "chest": "28-38 Fr", "foley": "10 Fr" },
"16yr": { "label": "16+ years (>50 kg)", "bvm": "Adult", "nasal": "22-36 Fr", "oral": "Medium 90 mm", "blade": "Miller 2 / MAC 3", "ett": "7.0-8.0", "lma": "4", "glidescope": "3 or 4", "iv": "18-20 ga", "cvl": "7 Fr", "ngt": "14-18 Fr", "chest": "28-42 Fr", "foley": "12 Fr" }
}
}

View file

@ -0,0 +1,52 @@
{
"version": "1.0",
"source": "Fenton TR, Kim JH. BMC Pediatrics 2013;13:59",
"weightLms": {
"male": {
"22": { "L": 0.21, "M": 496, "S": 0.17 },
"23": { "L": 0.21, "M": 575, "S": 0.17 },
"24": { "L": 0.21, "M": 660, "S": 0.17 },
"25": { "L": 0.21, "M": 762, "S": 0.16 },
"26": { "L": 0.21, "M": 870, "S": 0.16 },
"27": { "L": 0.20, "M": 993, "S": 0.15 },
"28": { "L": 0.20, "M": 1124, "S": 0.15 },
"29": { "L": 0.19, "M": 1272, "S": 0.14 },
"30": { "L": 0.18, "M": 1430, "S": 0.14 },
"31": { "L": 0.17, "M": 1607, "S": 0.14 },
"32": { "L": 0.15, "M": 1795, "S": 0.14 },
"33": { "L": 0.13, "M": 2008, "S": 0.13 },
"34": { "L": 0.12, "M": 2230, "S": 0.13 },
"35": { "L": 0.10, "M": 2467, "S": 0.13 },
"36": { "L": 0.08, "M": 2710, "S": 0.13 },
"37": { "L": 0.06, "M": 2948, "S": 0.12 },
"38": { "L": 0.04, "M": 3195, "S": 0.12 },
"39": { "L": 0.02, "M": 3380, "S": 0.12 },
"40": { "L": 0.01, "M": 3530, "S": 0.12 },
"41": { "L": 0.00, "M": 3660, "S": 0.12 },
"42": { "L": -0.02, "M": 3820, "S": 0.12 }
},
"female": {
"22": { "L": 0.23, "M": 474, "S": 0.17 },
"23": { "L": 0.22, "M": 538, "S": 0.17 },
"24": { "L": 0.22, "M": 610, "S": 0.17 },
"25": { "L": 0.22, "M": 705, "S": 0.16 },
"26": { "L": 0.22, "M": 810, "S": 0.16 },
"27": { "L": 0.21, "M": 920, "S": 0.15 },
"28": { "L": 0.21, "M": 1040, "S": 0.15 },
"29": { "L": 0.20, "M": 1178, "S": 0.14 },
"30": { "L": 0.19, "M": 1330, "S": 0.14 },
"31": { "L": 0.18, "M": 1500, "S": 0.14 },
"32": { "L": 0.16, "M": 1680, "S": 0.14 },
"33": { "L": 0.14, "M": 1880, "S": 0.13 },
"34": { "L": 0.12, "M": 2090, "S": 0.13 },
"35": { "L": 0.10, "M": 2310, "S": 0.13 },
"36": { "L": 0.08, "M": 2540, "S": 0.13 },
"37": { "L": 0.06, "M": 2766, "S": 0.12 },
"38": { "L": 0.04, "M": 3000, "S": 0.12 },
"39": { "L": 0.02, "M": 3180, "S": 0.12 },
"40": { "L": 0.01, "M": 3340, "S": 0.12 },
"41": { "L": 0.00, "M": 3480, "S": 0.12 },
"42": { "L": -0.02, "M": 3630, "S": 0.12 }
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,24 @@
{
"version": "1.0",
"source": "Harriet Lane Handbook / AHA PALS 2020",
"categories": {
"cardiac": { "label": "Cardiac", "color": "#ef4444" },
"metabolic": { "label": "Metabolic", "color": "#3b82f6" },
"reversal": { "label": "Reversal", "color": "#10b981" }
},
"medications": [
{ "name": "Adenosine", "indication": "SVT", "category": "cardiac", "route": "IV/IO rapid bolus", "formula": "adenosine" },
{ "name": "Amiodarone", "indication": "VT / VF", "category": "cardiac", "route": "IV/IO", "formula": "amiodarone" },
{ "name": "Atropine", "indication": "Bradycardia", "category": "cardiac", "route": "IV/IO/IM", "formula": "atropine" },
{ "name": "Calcium Chloride 10%", "indication": "Hypocalcemia / Hyperkalemia", "category": "metabolic", "route": "IV/IO", "formula": "calciumChloride" },
{ "name": "Calcium Gluconate 10%", "indication": "Hypocalcemia / Hyperkalemia", "category": "metabolic", "route": "IV/IO", "formula": "calciumGluconate" },
{ "name": "Dextrose", "indication": "Hypoglycemia", "category": "metabolic", "route": "IV", "formula": "dextrose" },
{ "name": "Epinephrine", "indication": "Pulseless arrest / Anaphylaxis", "category": "cardiac", "route": "IV/IO/IM/ETT", "formula": "epinephrine" },
{ "name": "Hydrocortisone", "indication": "Adrenal crisis", "category": "metabolic", "route": "IV/IM/IO", "formula": "hydrocortisone" },
{ "name": "Insulin (Regular)", "indication": "Hyperkalemia", "category": "metabolic", "route": "IV", "formula": "insulin" },
{ "name": "Lidocaine", "indication": "Antiarrhythmic", "category": "cardiac", "route": "IV/IO", "formula": "lidocaine" },
{ "name": "Magnesium Sulfate", "indication": "Torsades de Pointes", "category": "cardiac", "route": "IV/IO", "formula": "magnesiumSulfate" },
{ "name": "Naloxone", "indication": "Opioid overdose", "category": "reversal", "route": "IV/IO/IM/IN/ETT", "formula": "naloxone" },
{ "name": "Sodium Bicarbonate", "indication": "Metabolic acidosis", "category": "metabolic", "route": "IV/IO", "formula": "sodiumBicarbonate" }
]
}

View file

@ -0,0 +1,141 @@
{
"version": "1.0",
"source": "Harriet Lane Handbook 23rd Edition reference values",
"ageGroups": {
"premie": {
"label": "Premie",
"hr": { "awake": "120-170", "sleeping": "100-150" },
"rr": "40-70",
"sbp": "55-75",
"dbp": "35-45",
"temp": "36.5-37.5",
"weight": "0.5-2.5 kg",
"spo2": "88-95% (target)",
"notes": [
"HR and RR are highly variable and depend on gestational age",
"BP increases with gestational age and postnatal age",
"Target SpO2 88-95% to reduce retinopathy of prematurity risk",
"Temperature instability is common - use servo-controlled warmers",
"Bradycardia (<100 bpm) and apnea are common in premature infants"
]
},
"0-3mo": {
"label": "0-3 Months",
"hr": { "awake": "100-150", "sleeping": "85-135" },
"rr": "35-55",
"sbp": "65-85",
"dbp": "45-55",
"temp": "36.5-37.5",
"weight": "2.5-6 kg",
"spo2": ">95%",
"notes": [
"HR normally increases with crying (up to 180-190 bpm) - this is physiologic",
"Periodic breathing (pauses <10 sec) is normal in neonates",
"Acrocyanosis (blue hands/feet) is normal; central cyanosis is not",
"BP is best measured in the right arm (pre-ductal) in neonates",
"Normal weight loss of 5-7% in first 3-5 days; regain by 10-14 days"
]
},
"3-6mo": {
"label": "3-6 Months",
"hr": { "awake": "90-120", "sleeping": "75-110" },
"rr": "30-45",
"sbp": "70-90",
"dbp": "50-65",
"temp": "36.5-37.5",
"weight": "5-8 kg",
"spo2": ">95%",
"notes": [
"Expected weight gain: 20-30 g/day (150-200 g/week)",
"HR gradually decreases as vagal tone matures",
"RR >60 at rest may indicate lower respiratory tract disease",
"BP should be measured with appropriate cuff size (width 40% of arm circumference)"
]
},
"6-12mo": {
"label": "6-12 Months",
"hr": { "awake": "80-120", "sleeping": "70-110" },
"rr": "25-40",
"sbp": "80-100",
"dbp": "55-65",
"temp": "36.0-37.5",
"weight": "8-10 kg",
"spo2": ">95%",
"notes": [
"Expected weight: triple birth weight by 12 months (~10 kg average)",
"Weight gain slows to ~10-15 g/day",
"Sinus arrhythmia (HR varies with breathing) is normal",
"Febrile tachycardia: HR increases ~10 bpm per 1 degree C above 37"
]
},
"1-3yr": {
"label": "1-3 Years",
"hr": { "awake": "70-110", "sleeping": "60-100" },
"rr": "20-30",
"sbp": "90-105",
"dbp": "55-70",
"temp": "36.0-37.5",
"weight": "10-15 kg",
"spo2": ">95%",
"notes": [
"Expected weight gain: ~200-250 g/month (2-2.5 kg/year)",
"Tachycardia: HR >110 at rest warrants evaluation",
"Tachypnea: RR >30 at rest may indicate respiratory distress",
"BP screening begins at age 3 per AAP 2017 guidelines",
"Estimated weight: 2 x (age in years) + 8"
]
},
"3-6yr": {
"label": "3-6 Years",
"hr": { "awake": "65-110", "sleeping": "55-100" },
"rr": "20-25",
"sbp": "95-110",
"dbp": "60-75",
"temp": "36.0-37.5",
"weight": "14-20 kg",
"spo2": ">95%",
"notes": [
"Annual BP screening recommended from age 3",
"Normal BP <90th percentile for age, sex, and height",
"Elevated BP: 90th to <95th percentile (or 120/80 if lower)",
"Estimated weight: 2 x (age in years) + 8",
"ETT size (uncuffed): (age/4) + 4"
]
},
"6-12yr": {
"label": "6-12 Years",
"hr": { "awake": "60-95", "sleeping": "50-85" },
"rr": "14-22",
"sbp": "100-120",
"dbp": "60-75",
"temp": "36.0-37.5",
"weight": "20-40 kg",
"spo2": ">95%",
"notes": [
"Resting HR >95 or <60 warrants evaluation",
"BP should be measured at every clinical encounter",
"Stage 1 HTN: >=95th percentile on 3 separate occasions",
"Estimated weight: 3 x (age in years) + 7",
"ETT size (cuffed): (age/4) + 3.5"
]
},
">12yr": {
"label": ">12 Years",
"hr": { "awake": "55-85", "sleeping": "45-75" },
"rr": "12-18",
"sbp": "110-135",
"dbp": "65-85",
"temp": "36.0-37.5",
"weight": "40-80 kg",
"spo2": ">95%",
"notes": [
"Vital signs approach adult values",
"From age 13: use adult BP thresholds (AAP 2017)",
"Normal: <120/<80 mmHg; Elevated: 120-129/<80 mmHg",
"Stage 1 HTN: 130-139/80-89 mmHg; Stage 2 HTN: >=140/>=90 mmHg",
"Orthostatic vitals: measure lying, sitting, standing if dizzy",
"Athletic bradycardia (HR 45-60) may be normal in trained adolescents"
]
}
}
}

4177
public/data/pe-guide.json Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -11,13 +11,19 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
integrity="sha384-/o6I2CkkWC//PSjvWC/eYN7l3xM3tJm8ZzVkCOfp//W05QcE3mlGskpoHB6XqI+B" crossorigin="anonymous" referrerpolicy="no-referrer"> integrity="sha384-/o6I2CkkWC//PSjvWC/eYN7l3xM3tJm8ZzVkCOfp//W05QcE3mlGskpoHB6XqI+B" crossorigin="anonymous" referrerpolicy="no-referrer">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> <!-- Explicit render mode: the register/forgot widgets live inside forms that
start hidden, and Turnstile's implicit auto-render does not reliably
complete a challenge inside a display:none container. auth.js renders
each widget the first time its form is shown. -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js" <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"
integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a" integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script> crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css"> <link rel="stylesheet" href="/vendor/katex/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/marked@14.1.3/marked.min.js" defer></script> <script src="/vendor/marked/marked.umd.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" defer></script> <script src="/vendor/markdown-it/markdown-it.min.js" defer></script>
<script src="/vendor/katex/katex.min.js" defer></script>
<script src="/vendor/mathjax/tex-mml-chtml.js" defer></script>
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb"> <meta name="theme-color" content="#2563eb">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
@ -68,7 +74,6 @@
<label>2FA Code</label> <label>2FA Code</label>
<input type="text" id="login-totp" placeholder="6-digit code" maxlength="6"> <input type="text" id="login-totp" placeholder="6-digit code" maxlength="6">
</div> </div>
<div class="cf-turnstile" id="turnstile-login" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
<button type="submit" class="btn-auth" id="btn-local-login">Sign In</button> <button type="submit" class="btn-auth" id="btn-local-login">Sign In</button>
<div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;"> <div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;">
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span> <span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span>
@ -102,7 +107,7 @@
<label>Password (8+ characters)</label> <label>Password (8+ characters)</label>
<input type="password" id="reg-password" required minlength="8" placeholder="••••••••"> <input type="password" id="reg-password" required minlength="8" placeholder="••••••••">
</div> </div>
<div class="cf-turnstile" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div> <div id="turnstile-register" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></div>
<button type="submit" class="btn-auth">Create Account</button> <button type="submit" class="btn-auth">Create Account</button>
<div class="auth-links"> <div class="auth-links">
<a href="#" id="show-login">Back to sign in</a> <a href="#" id="show-login">Back to sign in</a>
@ -116,7 +121,7 @@
<label>Email</label> <label>Email</label>
<input type="email" id="forgot-email" required placeholder="your@email.com"> <input type="email" id="forgot-email" required placeholder="your@email.com">
</div> </div>
<div class="cf-turnstile" id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div> <div id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></div>
<button type="submit" class="btn-auth">Send Reset Link</button> <button type="submit" class="btn-auth">Send Reset Link</button>
<div class="auth-links"> <div class="auth-links">
<a href="#" id="show-login-2">Back to sign in</a> <a href="#" id="show-login-2">Back to sign in</a>
@ -129,7 +134,7 @@
</div> </div>
<div id="apk-download-link" style="text-align:center;margin:14px 0 0;font-size:13px;"> <div id="apk-download-link" style="text-align:center;margin:14px 0 0;font-size:13px;">
<a href="https://github.com/ifedan-ed/pediatric-ai-scribe-v3/releases/latest" target="_blank" rel="noopener" style="color:#2563eb;text-decoration:none;font-weight:500;"> <a href="https://git.danvics.com/danvics/pediatric-ai-scribe-v3/releases/latest" target="_blank" rel="noopener" style="color:#2563eb;text-decoration:none;font-weight:500;">
<i class="fas fa-mobile-screen"></i> Download Android app (APK) <i class="fas fa-mobile-screen"></i> Download Android app (APK)
</a> </a>
</div> </div>
@ -170,11 +175,6 @@
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<div class="model-selector">
<label><i class="fas fa-robot"></i></label>
<select id="global-model-select"></select>
<span id="model-cost-badge" class="cost-badge" style="display:none;"></span>
</div>
<div class="header-buttons"> <div class="header-buttons">
<button id="btn-settings" class="btn-header" title="Settings"> <button id="btn-settings" class="btn-header" title="Settings">
<i class="fas fa-cog"></i> <i class="fas fa-cog"></i>
@ -391,9 +391,9 @@
<ul style="font-size:12.5px;color:var(--g700);line-height:1.8;padding-left:18px;margin:0 0 12px;"> <ul style="font-size:12.5px;color:var(--g700);line-height:1.8;padding-left:18px;margin:0 0 12px;">
<li>Multiple AI providers with HIPAA-compliant options (BAA-covered)</li> <li>Multiple AI providers with HIPAA-compliant options (BAA-covered)</li>
<li>Multiple transcription engines including medical-specific models</li> <li>Multiple transcription engines including medical-specific models</li>
<li>Browser Whisper for fully offline, private transcription</li> <li>Configurable speech-to-text with server-side providers</li>
<li>Text-to-speech for reading notes aloud</li> <li>Text-to-speech for reading notes aloud</li>
<li>AI learns from your edits over time (correction tracking)</li> <li>AI can follow your saved note templates and formatting preferences</li>
<li>Customizable AI prompts for each note type</li> <li>Customizable AI prompts for each note type</li>
</ul> </ul>
@ -473,43 +473,41 @@
integrity="sha384-JUh163oCRItcbPme8pYnROHQMC6fNKTBWtRG3I3I0erJkzNgL7uxKlNwcrcFKeqF" integrity="sha384-JUh163oCRItcbPme8pYnROHQMC6fNKTBWtRG3I3I0erJkzNgL7uxKlNwcrcFKeqF"
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script> crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
<script defer src="/js/milestonesData.js"></script> <script defer src="/js/milestonesData.js"></script>
<script defer src="/js/pediatricScheduleData.js"></script> <script type="module" src="/js/audioBackup.js"></script>
<script defer src="/js/audioBackup.js"></script> <script type="module" src="/js/speechRecognition.js"></script>
<script defer src="/js/browserWhisper.js"></script> <script type="module" src="/js/transcriptionSettings.js"></script>
<script defer src="/js/speechRecognition.js"></script> <script type="module" src="/js/voicePreferences.js"></script>
<script defer src="/js/transcriptionSettings.js"></script>
<script defer src="/js/voicePreferences.js"></script>
<script defer src="/js/app.js?v=7.1.3"></script> <script defer src="/js/app.js?v=7.1.3"></script>
<script defer src="/js/ui-state.js"></script> <script type="module" src="/js/ui-state.js"></script>
<script defer src="/js/secureStorage.js"></script> <script type="module" src="/js/secureStorage.js"></script>
<script defer src="/js/authFetch.js"></script> <script type="module" src="/js/authFetch.js"></script>
<script defer src="/js/auth.js"></script> <script defer src="/js/auth.js"></script>
<script defer src="/js/liveEncounter.js"></script> <script defer src="/js/liveEncounter.js"></script>
<script defer src="/js/voiceDictation.js"></script> <script defer src="/js/voiceDictation.js"></script>
<script defer src="/js/hospitalCourse.js"></script> <script type="module" src="/js/hospitalCourse.js"></script>
<script defer src="/js/chartReview.js"></script> <script type="module" src="/js/chartReview.js"></script>
<script defer src="/js/soap.js"></script> <script type="module" src="/js/soap.js"></script>
<script defer src="/js/milestones.js"></script> <script type="module" src="/js/milestones.js"></script>
<script defer src="/js/peGuide.js"></script> <script type="module" src="/js/peGuide.js"></script>
<script defer src="/js/extensions.js"></script> <script type="module" src="/js/extensions.js"></script>
<script defer src="/js/notes.js"></script> <script type="module" src="/js/notes.js"></script>
<script defer src="/js/diagrams.js"></script> <script type="module" src="/js/diagrams.js"></script>
<script defer src="/js/clinicalAssistant.js"></script> <script type="module" src="/js/clinicalAssistant.js"></script>
<script defer src="/js/nextcloud.js"></script> <script type="module" src="/js/nextcloud.js"></script>
<script defer src="/js/wellVisit.js"></script> <script type="module" src="/js/wellVisit.js"></script>
<script defer src="/js/shadess.js"></script> <script defer src="/js/shadess.js"></script>
<script defer src="/js/sickVisit.js"></script> <script type="module" src="/js/sickVisit.js"></script>
<script defer src="/js/ed-encounters.js"></script> <script type="module" src="/js/ed-encounters.js"></script>
<script defer src="/js/encounters.js"></script> <script defer src="/js/encounters.js"></script>
<script defer src="/js/memories.js"></script> <script type="module" src="/js/memories.js"></script>
<script defer src="/js/admin-docs.js"></script> <script type="module" src="/js/admin-docs.js"></script>
<script defer src="/js/documents.js"></script> <script type="module" src="/js/documents.js"></script>
<script defer src="/js/calc-math.js"></script> <script defer src="/js/calc-math.js"></script>
<script defer src="/js/drugs-loader.js"></script> <script type="module" src="/js/drugs-loader.js"></script>
<script defer src="/js/calculators.js"></script> <script type="module" src="/js/calculators.js"></script>
<script type="module" src="/js/bedside/index.js"></script> <script type="module" src="/js/bedside/index.js"></script>
<script defer src="/js/learningHub.js"></script> <script type="module" src="/js/learningHub.js"></script>
<script defer src="/js/admin.js?v=7.1.3"></script> <script type="module" src="/js/admin.js?v=7.1.3"></script>
<!-- ═══════════ IMAGE LIGHTBOX (global overlay — triggered by any <!-- ═══════════ IMAGE LIGHTBOX (global overlay — triggered by any
[data-img-src] button in any tab; lives here so it exists in the [data-img-src] button in any tab; lives here so it exists in the

View file

@ -11,14 +11,12 @@
// auth.js after login when user.role === 'admin'. // auth.js after login when user.role === 'admin'.
// ============================================================ // ============================================================
(function () {
'use strict';
var _inited = false; var _inited = false;
var _treeLoaded = false; var _treeLoaded = false;
var _tree = []; var _tree = [];
var _flatFiles = []; // flat list of {name, path, parent} for filter var _flatFiles = []; // flat list of {name, path, parent} for filter
var _expanded = {}; // map of dir-path → bool, persisted in UIState var _expanded = {}; // map of dir-path → bool, persisted in UIState
var _currentPath = '';
function $(id) { return document.getElementById(id); } function $(id) { return document.getElementById(id); }
function escHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); } function escHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
@ -110,6 +108,123 @@
}).join(''); }).join('');
} }
// marked no longer guarantees heading ids across versions, and the docs
// reader is an internal scroll container. Add stable GitHub-style ids and
// handle same-page #toc links by scrolling the reader, not the window.
function slugHeading(text) {
return String(text || '')
.trim()
.toLowerCase()
.replace(/\s/g, '-')
.replace(/[^a-z0-9_-]/g, '');
}
function prepareDocAnchors(body) {
if (!body) return;
var seen = {};
body.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(function (h) {
var base = slugHeading(h.textContent || '') || h.id;
if (!base) return;
var id = base;
var n = seen[base] || 0;
if (n) id = base + '-' + n;
seen[base] = n + 1;
h.id = id;
});
}
function prepareDocLinks(body) {
if (!body) return;
body.querySelectorAll('a[href]').forEach(function (link) {
var href = link.getAttribute('href') || '';
if (href.charAt(0) === '#') {
link.dataset.docHash = href;
link.classList.add('docs-anchor-link');
return;
}
if (/\.md(#.*)?$/i.test(href)) {
var parts = href.split('#');
link.dataset.docFile = resolveDocPath(parts[0] || _currentPath);
if (parts[1]) link.dataset.docHash = '#' + parts[1];
link.classList.add('docs-anchor-link');
return;
}
if (link.hash) {
try {
var u = new URL(link.href, window.location.href);
if (u.origin !== window.location.origin || u.pathname !== window.location.pathname) return;
link.dataset.docHash = u.hash;
link.classList.add('docs-anchor-link');
} catch (_) { return; }
}
});
}
function resolveDocPath(href) {
if (!href) return _currentPath;
if (href.charAt(0) === '/') return href.replace(/^\/+/, '');
var base = _currentPath.split('/');
base.pop();
href.split('/').forEach(function (part) {
if (!part || part === '.') return;
if (part === '..') base.pop();
else base.push(part);
});
return base.join('/');
}
function scrollReaderToHash(hash) {
var body = $('docs-reader-body');
var reader = $('docs-reader');
if (!body || !reader || !hash) return false;
var rawId = String(hash).replace(/^#/, '');
var id;
try { id = decodeURIComponent(rawId); } catch (_) { id = rawId; }
if (!id) return false;
var target = document.getElementById(id);
if (!target || !body.contains(target)) {
target = Array.prototype.slice.call(body.querySelectorAll('h1,h2,h3,h4,h5,h6')).find(function (h) {
return slugHeading(h.textContent || '') === id;
});
}
if (!target || !body.contains(target)) return false;
var readerBox = reader.getBoundingClientRect();
var targetBox = target.getBoundingClientRect();
reader.scrollTop += targetBox.top - readerBox.top - 8;
return true;
}
function handleDocAnchorClick(e) {
var body = $('docs-reader-body');
var link = e.target.closest('a, [data-doc-hash], [data-doc-file]');
if (!link || !body || !body.contains(link)) return false;
var href = link.dataset.docHash || link.getAttribute('href') || '';
var file = link.dataset.docFile || '';
if (!file && href && /\.md(#.*)?$/i.test(href)) {
var parts = href.split('#');
file = resolveDocPath(parts[0] || _currentPath);
href = parts[1] ? '#' + parts[1] : '';
}
if (!file && (!href || href.charAt(0) !== '#')) return false;
e.preventDefault();
if (file && file !== _currentPath) {
loadFile(file, href);
markActive(file);
return true;
}
requestAnimationFrame(function () {
if (scrollReaderToHash(href)) {
try { history.replaceState(null, '', href); } catch (_) {}
}
});
return true;
}
function handleDocAnchorKeydown(e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
if (handleDocAnchorClick(e)) e.preventDefault();
}
function loadTree() { function loadTree() {
if (_treeLoaded) return; if (_treeLoaded) return;
fetch('/api/admin/docs/tree', { headers: getAuthHeaders() }) fetch('/api/admin/docs/tree', { headers: getAuthHeaders() })
@ -142,10 +257,11 @@
}); });
} }
function loadFile(relPath) { function loadFile(relPath, hash) {
var body = $('docs-reader-body'); var body = $('docs-reader-body');
var meta = $('docs-reader-meta'); var meta = $('docs-reader-meta');
if (!body) return; if (!body) return;
_currentPath = relPath;
body.innerHTML = '<div class="docs-loading">Loading…</div>'; body.innerHTML = '<div class="docs-loading">Loading…</div>';
fetch('/api/admin/docs/file?path=' + encodeURIComponent(relPath), { headers: getAuthHeaders() }) fetch('/api/admin/docs/file?path=' + encodeURIComponent(relPath), { headers: getAuthHeaders() })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
@ -155,6 +271,8 @@
return; return;
} }
body.innerHTML = data.html || ''; body.innerHTML = data.html || '';
prepareDocAnchors(body);
prepareDocLinks(body);
if (meta) { if (meta) {
meta.textContent = relPath + ' • ' + (data.bytes != null ? (data.bytes + ' bytes') : ''); meta.textContent = relPath + ' • ' + (data.bytes != null ? (data.bytes + ' bytes') : '');
} }
@ -163,6 +281,8 @@
// Scroll content area to top so deep-link readers don't land mid-doc // Scroll content area to top so deep-link readers don't land mid-doc
var reader = $('docs-reader'); var reader = $('docs-reader');
if (reader) reader.scrollTop = 0; if (reader) reader.scrollTop = 0;
var targetHash = hash || window.location.hash;
if (targetHash) setTimeout(function () { scrollReaderToHash(targetHash); }, 0);
}) })
.catch(function (err) { .catch(function (err) {
body.innerHTML = '<p style="color:var(--red);">' + escHtml(err.message || String(err)) + '</p>'; body.innerHTML = '<p style="color:var(--red);">' + escHtml(err.message || String(err)) + '</p>';
@ -183,6 +303,7 @@
// ── Wire events ──────────────────────────────────────────────────── // ── Wire events ────────────────────────────────────────────────────
function init() { function init() {
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
if (handleDocAnchorClick(e)) return;
var fileBtn = e.target.closest('.docs-file-btn'); var fileBtn = e.target.closest('.docs-file-btn');
if (fileBtn) { if (fileBtn) {
var p = fileBtn.dataset.file; var p = fileBtn.dataset.file;
@ -206,9 +327,17 @@
arrow.classList.toggle('fa-chevron-right', !willOpen); arrow.classList.toggle('fa-chevron-right', !willOpen);
} }
persistExpanded(); persistExpanded();
return;
} }
}); });
var body = $('docs-reader-body');
if (body && !body.dataset.anchorsWired) {
body.dataset.anchorsWired = '1';
body.addEventListener('click', handleDocAnchorClick);
body.addEventListener('keydown', handleDocAnchorKeydown);
}
var filter = $('docs-filter'); var filter = $('docs-filter');
if (filter) { if (filter) {
var t = null; var t = null;
@ -230,4 +359,3 @@
}); });
console.log('Admin docs viewer loaded'); console.log('Admin docs viewer loaded');
})();

View file

@ -2,9 +2,36 @@
// ADMIN.JS — Admin panel: users, settings, stats // ADMIN.JS — Admin panel: users, settings, stats
// ============================================================ // ============================================================
(function() { function adminEscapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
var loaded = false; function adminTableMessage(colspan, color, text) {
return '<tr><td colspan="' + colspan + '" style="text-align:center;color:' + color + ';padding:20px;">' + adminEscapeHtml(text) + '</td></tr>';
}
function adminSetButtonText(btn, text, disabled) {
if (!btn) return;
btn.textContent = text;
btn.disabled = !!disabled;
}
function adminSetButtonHtml(btn, html, disabled) {
if (!btn) return;
btn.innerHTML = html;
btn.disabled = !!disabled;
}
function adminFlashButtonBackground(btn, color) {
if (!btn) return;
btn.style.background = color || '';
setTimeout(function() { if (btn) btn.style.background = ''; }, 2000);
}
{
let loaded = false;
// Load admin panel when admin tab is activated // Load admin panel when admin tab is activated
document.addEventListener('tabChanged', function(e) { document.addEventListener('tabChanged', function(e) {
@ -86,15 +113,15 @@
function loadUsers() { function loadUsers() {
var tbody = document.getElementById('admin-users-body'); var tbody = document.getElementById('admin-users-body');
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--g400);padding:20px;">Loading...</td></tr>'; tbody.innerHTML = adminTableMessage(5, 'var(--g400)', 'Loading...');
fetch('/api/admin/users', { headers: getAuthHeaders() }) fetch('/api/admin/users', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (!data.success) { tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--red);padding:20px;">Failed to load users</td></tr>'; return; } if (!data.success) { tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Failed to load users'); return; }
renderUsers(data.users || []); renderUsers(data.users || []);
}) })
.catch(function() { tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--red);padding:20px;">Request failed</td></tr>'; }); .catch(function() { tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Request failed'); });
} }
function renderUsers(users) { function renderUsers(users) {
@ -104,7 +131,7 @@
var currentUser = JSON.parse(localStorage.getItem('ped_scribe_user') || '{}'); var currentUser = JSON.parse(localStorage.getItem('ped_scribe_user') || '{}');
if (users.length === 0) { if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--g400);padding:20px;">No users found</td></tr>'; tbody.innerHTML = adminTableMessage(5, 'var(--g400)', 'No users found');
return; return;
} }
@ -253,19 +280,16 @@
.catch(function() { showToast('Request failed', 'error'); }); .catch(function() { showToast('Request failed', 'error'); });
} }
function esc(str) { const esc = adminEscapeHtml;
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
})(); }
// ============================================================ // ============================================================
// ADMIN CMS — Announcements, Feature Flags, Email, AI Prompts // ADMIN CMS — Announcements, Feature Flags, Email, AI Prompts
// ============================================================ // ============================================================
(function() { {
var cmsLoaded = false; let cmsLoaded = false;
// Load CMS when admin tab is opened (via tabChanged event or click) // Load CMS when admin tab is opened (via tabChanged event or click)
document.addEventListener('tabChanged', function(e) { document.addEventListener('tabChanged', function(e) {
@ -657,15 +681,15 @@
}); });
} }
})(); }
// ============================================================ // ============================================================
// ADMIN CLINICAL ASSISTANT SETTINGS // ADMIN CLINICAL ASSISTANT SETTINGS
// ============================================================ // ============================================================
(function() { {
var loaded = false; let loaded = false;
var defaults = { const defaults = {
behavior: 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting or too vague, answer briefly and ask what they want to look up. Synthesize across sources, cite claims with numbered citations like [1], and list sources at the bottom by title/resource and page. Do not invent citations.' behavior: 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting or too vague, answer briefly and ask what they want to look up. Synthesize across sources and cite factual claims with the exact provided source numbers like [1]. Do not invent, renumber, merge, or move citations.'
}; };
document.addEventListener('tabChanged', function(e) { document.addEventListener('tabChanged', function(e) {
@ -677,8 +701,11 @@
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin(); if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin();
if (e.target.closest('#btn-test-assistant-chat-model')) testAssistantChatModel(); if (e.target.closest('#btn-test-assistant-chat-model')) testAssistantChatModel();
if (e.target.closest('#btn-regenerate-assistant-prompt-pool')) regenerateAssistantPromptPool();
if (e.target.closest('#btn-restore-assistant-prompt-pool')) restoreAssistantPromptPool();
if (e.target.closest('#btn-refresh-assistant-image-models')) loadAssistantImageModels(); if (e.target.closest('#btn-refresh-assistant-image-models')) loadAssistantImageModels();
if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel(); if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel();
if (e.target.closest('#btn-use-custom-assistant-image-model')) useCustomAssistantImageModel();
}); });
function loadAssistantAdmin() { function loadAssistantAdmin() {
@ -716,6 +743,7 @@
} }
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || ''; window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
loadAssistantImageModels(); loadAssistantImageModels();
loadAssistantPromptPoolStatus();
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8'); setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400'); setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
setValue('assistant-system-behavior', cfg['clinical_assistant.system_behavior'] || defaults.behavior); setValue('assistant-system-behavior', cfg['clinical_assistant.system_behavior'] || defaults.behavior);
@ -785,6 +813,91 @@
return match ? match[1] : ''; return match ? match[1] : '';
} }
function loadAssistantPromptPoolStatus() {
var result = document.getElementById('assistant-prompt-pool-status');
if (result) result.textContent = 'Checking prompt pool...';
fetch('/api/admin/clinical-assistant/prompt-pool', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Prompt pool status failed');
renderAssistantPromptPoolStatus(data.meta);
renderAssistantPromptPoolSnapshots(data.snapshots || []);
})
.catch(function(err) {
if (result) result.textContent = err.message;
});
}
function regenerateAssistantPromptPool() {
var result = document.getElementById('assistant-prompt-pool-status');
var btn = document.getElementById('btn-regenerate-assistant-prompt-pool');
if (btn) btn.disabled = true;
if (result) result.textContent = 'Regenerating prompt pool. This can take several minutes...';
fetch('/api/admin/clinical-assistant/prompt-pool/regenerate', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({})
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Prompt pool regeneration failed');
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now() });
renderAssistantPromptPoolSnapshots(data.snapshots || []);
showToast('Prompt pool regenerated', 'success');
}).catch(function(err) {
if (result) result.textContent = err.message;
showToast(err.message, 'error');
}).finally(function() {
if (btn) btn.disabled = false;
});
}
function renderAssistantPromptPoolStatus(meta) {
var result = document.getElementById('assistant-prompt-pool-status');
if (!result) return;
if (!meta) {
result.textContent = 'No generated pool found. The assistant will use indexed-topic fallback until you generate one.';
return;
}
var generated = meta.generatedAt ? new Date(meta.generatedAt).toLocaleString() : 'unknown time';
result.textContent = 'Generated pool: ' + (meta.count || 0) + ' prompts, target ' + (meta.target || '?') + ', generated ' + generated + (meta.restoredFrom ? ', restored from snapshot #' + meta.restoredFrom : '') + '.';
}
function renderAssistantPromptPoolSnapshots(snapshots) {
var select = document.getElementById('assistant-prompt-pool-snapshots');
if (!select) return;
select.innerHTML = '';
if (!snapshots.length) {
var empty = document.createElement('option');
empty.value = '';
empty.textContent = 'No saved snapshots';
select.appendChild(empty);
return;
}
snapshots.forEach(function(s) {
var opt = document.createElement('option');
opt.value = s.id;
var date = s.created_at ? new Date(s.created_at).toLocaleString() : 'unknown time';
opt.textContent = '#' + s.id + ' - ' + (s.count || 0) + ' prompts - ' + date + (s.restored_from ? ' (restored from #' + s.restored_from + ')' : '');
select.appendChild(opt);
});
}
function restoreAssistantPromptPool() {
var select = document.getElementById('assistant-prompt-pool-snapshots');
var id = select ? select.value : '';
var result = document.getElementById('assistant-prompt-pool-status');
if (!id) { showToast('Select a prompt pool snapshot', 'error'); return; }
if (result) result.textContent = 'Restoring prompt pool snapshot #' + id + '...';
fetch('/api/admin/clinical-assistant/prompt-pool/restore', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ id: Number(id) })
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Prompt pool restore failed');
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now(), restoredFrom: id });
renderAssistantPromptPoolSnapshots(data.snapshots || []);
showToast('Prompt pool restored', 'success');
}).catch(function(err) {
if (result) result.textContent = err.message;
showToast(err.message, 'error');
});
}
function testAssistantImageModel() { function testAssistantImageModel() {
var model = getValue('assistant-image-model') || 'openai-gpt-image-1'; var model = getValue('assistant-image-model') || 'openai-gpt-image-1';
var result = document.getElementById('assistant-image-test-result'); var result = document.getElementById('assistant-image-test-result');
@ -794,7 +907,7 @@
}).then(function(r) { return r.json(); }).then(function(data) { }).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Image test failed'); if (!data.success) throw new Error(data.error || 'Image test failed');
var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : ''); var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
if (result) result.innerHTML = 'Image model OK (' + data.duration + ' ms)' + (src ? '<div style="margin-top:8px;"><img src="' + src + '" alt="test image" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : ''); if (result) result.innerHTML = 'Image model OK (' + data.duration + ' ms)' + (src ? '<div style="margin-top:8px;"><img src="' + escAssistant(src) + '" alt="test image" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : '');
showToast('Image model works', 'success'); showToast('Image model works', 'success');
}).catch(function(err) { }).catch(function(err) {
if (result) result.textContent = err.message; if (result) result.textContent = err.message;
@ -802,6 +915,22 @@
}); });
} }
function useCustomAssistantImageModel() {
var input = document.getElementById('assistant-custom-image-model');
var sel = document.getElementById('assistant-image-model');
var model = input ? input.value.trim() : '';
if (!model) { showToast('Enter an image model ID', 'error'); return; }
if (sel && !Array.prototype.some.call(sel.options, function(o) { return o.value === model; })) {
var opt = document.createElement('option');
opt.value = model;
opt.textContent = model + ' (custom)';
sel.appendChild(opt);
}
if (sel) sel.value = model;
window._assistantImageModelValue = model;
showToast('Custom image model selected. Save settings to keep it.', 'info');
}
function saveAssistantAdmin() { function saveAssistantAdmin() {
var status = document.getElementById('assistant-admin-status'); var status = document.getElementById('assistant-admin-status');
if (status) status.textContent = 'Saving...'; if (status) status.textContent = 'Saving...';
@ -830,13 +959,13 @@
} }
function getValue(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; } function getValue(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; }
function setValue(id, value) { var el = document.getElementById(id); if (el) el.value = value; } function setValue(id, value) { var el = document.getElementById(id); if (el) el.value = value; }
function escAssistant(str) { return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); } const escAssistant = adminEscapeHtml;
})(); }
// ============================================================ // ============================================================
// ADMIN MODEL MANAGEMENT — Discover, search, enable/disable, custom models // ADMIN MODEL MANAGEMENT — Discover, search, enable/disable, custom models
// ============================================================ // ============================================================
(function() { {
document.addEventListener('tabChanged', function(e) { document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') { if (e.detail && e.detail.tab === 'admin') {
@ -863,10 +992,7 @@
} }
}); });
function esc(str) { const esc = adminEscapeHtml;
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function loadAdminModels() { function loadAdminModels() {
fetch('/api/admin/config/models', { headers: getAuthHeaders() }) fetch('/api/admin/config/models', { headers: getAuthHeaders() })
@ -1151,7 +1277,7 @@
function testModel(modelId, btn) { function testModel(modelId, btn) {
if (!modelId) return; if (!modelId) return;
var origText = btn ? btn.textContent : 'Test'; var origText = btn ? btn.textContent : 'Test';
if (btn) { btn.textContent = '...'; btn.disabled = true; } adminSetButtonText(btn, '...', true);
fetch('/api/admin/config/models/test', { fetch('/api/admin/config/models/test', {
method: 'POST', method: 'POST',
@ -1160,7 +1286,7 @@
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (btn) { btn.textContent = origText; btn.disabled = false; } adminSetButtonText(btn, origText, false);
if (data.success) { if (data.success) {
showToast('"' + (data.response || '?') + '" — ' + modelId + ' (' + (data.duration || 0) + 'ms)', 'success'); showToast('"' + (data.response || '?') + '" — ' + modelId + ' (' + (data.duration || 0) + 'ms)', 'success');
} else { } else {
@ -1168,17 +1294,17 @@
} }
}) })
.catch(function() { .catch(function() {
if (btn) { btn.textContent = origText; btn.disabled = false; } adminSetButtonText(btn, origText, false);
showToast('Request failed', 'error'); showToast('Request failed', 'error');
}); });
} }
})(); }
// ============================================================ // ============================================================
// ADMIN TTS MANAGEMENT // ADMIN TTS MANAGEMENT
// ============================================================ // ============================================================
(function() { {
document.addEventListener('tabChanged', function(e) { document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') loadTTSConfig(); if (e.detail && e.detail.tab === 'admin') loadTTSConfig();
}); });
@ -1194,10 +1320,7 @@
if (e.target.id === 'admin-tts-search' && e.key === 'Enter') { e.preventDefault(); discoverTTS(); } if (e.target.id === 'admin-tts-search' && e.key === 'Enter') { e.preventDefault(); discoverTTS(); }
}); });
function esc(str) { const esc = adminEscapeHtml;
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function loadTTSConfig() { function loadTTSConfig() {
fetch('/api/admin/config/tts', { headers: getAuthHeaders() }) fetch('/api/admin/config/tts', { headers: getAuthHeaders() })
@ -1257,12 +1380,12 @@
} }
var items = data.voices || []; var items = data.voices || [];
if (items.length === 0) { if (items.length === 0) {
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No voices found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>'; container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No voices/models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
return; return;
} }
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' voices (provider: ' + esc(data.provider) + ')</p>' + container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' voices/models (provider: ' + esc(data.provider) + ')</p>' +
items.slice(0, 100).map(function(v) { items.slice(0, 100).map(function(v) {
var isModel = (v.source || '').indexOf('gateway') !== -1 || (v.source || '').indexOf('builtin-model') !== -1; var isModel = v.kind === 'model' || (v.source || '').indexOf('gateway') !== -1 || (v.source || '').indexOf('builtin-model') !== -1 || (v.source || '').indexOf('configured-model') !== -1;
var setType = isModel ? 'model' : 'voice'; var setType = isModel ? 'model' : 'voice';
var badge = isModel ? '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--blue);color:white;margin-left:4px;">MODEL</span>' : '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--green);color:white;margin-left:4px;">VOICE</span>'; var badge = isModel ? '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--blue);color:white;margin-left:4px;">MODEL</span>' : '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--green);color:white;margin-left:4px;">VOICE</span>';
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' + return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
@ -1280,7 +1403,7 @@
function setTTSDefault(id, type, btn) { function setTTSDefault(id, type, btn) {
var key = type === 'model' ? 'tts.model' : 'tts.voice'; var key = type === 'model' ? 'tts.model' : 'tts.voice';
var origText = btn ? btn.textContent : ''; var origText = btn ? btn.textContent : '';
if (btn) { btn.textContent = '...'; btn.disabled = true; } adminSetButtonText(btn, '...', true);
fetch('/api/admin/config/' + encodeURIComponent(key), { fetch('/api/admin/config/' + encodeURIComponent(key), {
method: 'PUT', method: 'PUT',
@ -1289,7 +1412,8 @@
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (btn) { btn.textContent = 'Set'; btn.disabled = false; btn.style.background = 'var(--green)'; setTimeout(function() { btn.style.background = ''; }, 2000); } adminSetButtonText(btn, 'Set', false);
adminFlashButtonBackground(btn, 'var(--green)');
if (data.success) { if (data.success) {
showToast('TTS ' + type + ' set to: ' + id, 'success'); showToast('TTS ' + type + ' set to: ' + id, 'success');
// Update voice selector // Update voice selector
@ -1309,7 +1433,7 @@
} }
}) })
.catch(function() { .catch(function() {
if (btn) { btn.textContent = origText; btn.disabled = false; } adminSetButtonText(btn, origText, false);
showToast('Request failed', 'error'); showToast('Request failed', 'error');
}); });
} }
@ -1320,7 +1444,7 @@
var btn = document.getElementById('btn-test-tts'); var btn = document.getElementById('btn-test-tts');
var resultEl = document.getElementById('admin-tts-result'); var resultEl = document.getElementById('admin-tts-result');
var audioEl = document.getElementById('admin-tts-audio'); var audioEl = document.getElementById('admin-tts-audio');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Synthesizing...'; } adminSetButtonHtml(btn, '<i class="fas fa-spinner fa-spin"></i> Synthesizing...', true);
if (resultEl) resultEl.textContent = ''; if (resultEl) resultEl.textContent = '';
if (audioEl) audioEl.style.display = 'none'; if (audioEl) audioEl.style.display = 'none';
@ -1331,7 +1455,7 @@
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-play"></i> Synthesize &amp; Play'; } adminSetButtonHtml(btn, '<i class="fas fa-play"></i> Synthesize &amp; Play', false);
if (!data.success) { if (!data.success) {
if (resultEl) resultEl.textContent = 'Error: ' + (data.error || 'Unknown error'); if (resultEl) resultEl.textContent = 'Error: ' + (data.error || 'Unknown error');
return; return;
@ -1345,7 +1469,7 @@
if (resultEl) resultEl.textContent = 'Provider: ' + (data.provider || '?') + ' · Voice: ' + (data.voice || '?'); if (resultEl) resultEl.textContent = 'Provider: ' + (data.provider || '?') + ' · Voice: ' + (data.voice || '?');
}) })
.catch(function(err) { .catch(function(err) {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-play"></i> Synthesize &amp; Play'; } adminSetButtonHtml(btn, '<i class="fas fa-play"></i> Synthesize &amp; Play', false);
if (resultEl) resultEl.textContent = 'Request failed: ' + err.message; if (resultEl) resultEl.textContent = 'Request failed: ' + err.message;
}); });
} }
@ -1357,15 +1481,15 @@
return new Blob([bytes], { type: type }); return new Blob([bytes], { type: type });
} }
})(); }
// ============================================================ // ============================================================
// ADMIN STT MANAGEMENT // ADMIN STT MANAGEMENT
// ============================================================ // ============================================================
(function() { {
var mediaRecorder = null; let mediaRecorder = null;
var audioChunks = []; let audioChunks = [];
var recording = false; let recording = false;
document.addEventListener('tabChanged', function(e) { document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') loadSTTConfig(); if (e.detail && e.detail.tab === 'admin') loadSTTConfig();
@ -1382,10 +1506,7 @@
if (e.target.id === 'admin-stt-search' && e.key === 'Enter') { e.preventDefault(); discoverSTT(); } if (e.target.id === 'admin-stt-search' && e.key === 'Enter') { e.preventDefault(); discoverSTT(); }
}); });
function esc(str) { const esc = adminEscapeHtml;
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function loadSTTConfig() { function loadSTTConfig() {
fetch('/api/admin/config/stt', { headers: getAuthHeaders() }) fetch('/api/admin/config/stt', { headers: getAuthHeaders() })
@ -1448,7 +1569,7 @@
function setSTTDefault(modelId, btn) { function setSTTDefault(modelId, btn) {
var origText = btn ? btn.textContent : ''; var origText = btn ? btn.textContent : '';
if (btn) { btn.textContent = '...'; btn.disabled = true; } adminSetButtonText(btn, '...', true);
fetch('/api/admin/config/' + encodeURIComponent('stt.model'), { fetch('/api/admin/config/' + encodeURIComponent('stt.model'), {
method: 'PUT', method: 'PUT',
headers: getAuthHeaders(), headers: getAuthHeaders(),
@ -1456,12 +1577,13 @@
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (btn) { btn.textContent = 'Set'; btn.disabled = false; btn.style.background = data.success ? 'var(--green)' : ''; setTimeout(function() { if (btn) btn.style.background = ''; }, 2000); } adminSetButtonText(btn, 'Set', false);
adminFlashButtonBackground(btn, data.success ? 'var(--green)' : '');
if (data.success) { showToast('STT model set to: ' + modelId, 'success'); loadSTTConfig(); } if (data.success) { showToast('STT model set to: ' + modelId, 'success'); loadSTTConfig(); }
else showToast(data.error || 'Failed', 'error'); else showToast(data.error || 'Failed', 'error');
}) })
.catch(function() { .catch(function() {
if (btn) { btn.textContent = origText; btn.disabled = false; } adminSetButtonText(btn, origText, false);
showToast('Request failed', 'error'); showToast('Request failed', 'error');
}); });
} }
@ -1547,12 +1669,12 @@
reader.readAsDataURL(blob); reader.readAsDataURL(blob);
} }
})(); }
// ============================================================ // ============================================================
// ADMIN EMBEDDING MODELS MANAGEMENT // ADMIN EMBEDDING MODELS MANAGEMENT
// ============================================================ // ============================================================
(function() { {
document.addEventListener('tabChanged', function(e) { document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') loadEmbeddingConfig(); if (e.detail && e.detail.tab === 'admin') loadEmbeddingConfig();
}); });
@ -1568,10 +1690,7 @@
if (e.target.id === 'admin-embed-search' && e.key === 'Enter') { e.preventDefault(); discoverEmbeddings(); } if (e.target.id === 'admin-embed-search' && e.key === 'Enter') { e.preventDefault(); discoverEmbeddings(); }
}); });
function esc(str) { const esc = adminEscapeHtml;
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function loadEmbeddingConfig() { function loadEmbeddingConfig() {
fetch('/api/admin/config/embeddings', { headers: getAuthHeaders() }) fetch('/api/admin/config/embeddings', { headers: getAuthHeaders() })
@ -1645,7 +1764,7 @@
function setEmbeddingDefault(modelId, dims, btn) { function setEmbeddingDefault(modelId, dims, btn) {
var origText = btn ? btn.textContent : ''; var origText = btn ? btn.textContent : '';
if (btn) { btn.textContent = '...'; btn.disabled = true; } adminSetButtonText(btn, '...', true);
var promises = [ var promises = [
fetch('/api/admin/config/' + encodeURIComponent('embeddings.model'), { fetch('/api/admin/config/' + encodeURIComponent('embeddings.model'), {
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: modelId }) method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: modelId })
@ -1661,12 +1780,13 @@
Promise.all(promises) Promise.all(promises)
.then(function(results) { .then(function(results) {
var ok = results.every(function(r) { return r.success; }); var ok = results.every(function(r) { return r.success; });
if (btn) { btn.textContent = 'Set'; btn.disabled = false; btn.style.background = ok ? 'var(--green)' : ''; setTimeout(function() { if (btn) btn.style.background = ''; }, 2000); } adminSetButtonText(btn, 'Set', false);
adminFlashButtonBackground(btn, ok ? 'var(--green)' : '');
if (ok) { showToast('Embedding model set to: ' + modelId + (dims ? ' (' + dims + 'd)' : ''), 'success'); loadEmbeddingConfig(); } if (ok) { showToast('Embedding model set to: ' + modelId + (dims ? ' (' + dims + 'd)' : ''), 'success'); loadEmbeddingConfig(); }
else showToast(results[0].error || 'Failed', 'error'); else showToast(results[0].error || 'Failed', 'error');
}) })
.catch(function() { .catch(function() {
if (btn) { btn.textContent = origText; btn.disabled = false; } adminSetButtonText(btn, origText, false);
showToast('Request failed', 'error'); showToast('Request failed', 'error');
}); });
} }
@ -1675,7 +1795,7 @@
var text = (document.getElementById('admin-embed-test-text') || {}).value || 'test'; var text = (document.getElementById('admin-embed-test-text') || {}).value || 'test';
var resultEl = document.getElementById('admin-embed-result'); var resultEl = document.getElementById('admin-embed-result');
var btn = document.getElementById('btn-test-embedding'); var btn = document.getElementById('btn-test-embedding');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; } adminSetButtonHtml(btn, '<i class="fas fa-spinner fa-spin"></i>', true);
if (resultEl) resultEl.textContent = 'Generating...'; if (resultEl) resultEl.textContent = 'Generating...';
fetch('/api/admin/config/embeddings/test', { fetch('/api/admin/config/embeddings/test', {
@ -1685,7 +1805,7 @@
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-code-branch"></i> Generate'; } adminSetButtonHtml(btn, '<i class="fas fa-code-branch"></i> Generate', false);
if (!data.success) { if (!data.success) {
if (resultEl) resultEl.innerHTML = '<span style="color:var(--red);">Error: ' + esc(data.error || 'Failed') + '</span>'; if (resultEl) resultEl.innerHTML = '<span style="color:var(--red);">Error: ' + esc(data.error || 'Failed') + '</span>';
return; return;
@ -1699,9 +1819,9 @@
} }
}) })
.catch(function(err) { .catch(function(err) {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-code-branch"></i> Generate'; } adminSetButtonHtml(btn, '<i class="fas fa-code-branch"></i> Generate', false);
if (resultEl) resultEl.textContent = 'Request failed: ' + err.message; if (resultEl) resultEl.textContent = 'Request failed: ' + err.message;
}); });
} }
})(); }

View file

@ -3,30 +3,38 @@
// ============================================================ // ============================================================
// ── Client-side error logging ────────────────────────────── // ── Client-side error logging ──────────────────────────────
(function() { function sendError(data) {
function sendError(data) { try {
try { navigator.sendBeacon('/api/logs/client-error', new Blob(
navigator.sendBeacon('/api/logs/client-error', new Blob( [JSON.stringify(data)], { type: 'application/json' }
[JSON.stringify(data)], { type: 'application/json' } ));
)); } catch(e) {}
} catch(e) {} }
}
window.onerror = function(msg, src, line, col, err) { window.onerror = function(msg, src, line, col, err) {
// Ignore errors from browser extensions // Ignore errors from browser extensions
if (src && src.indexOf('moz-extension') !== -1) return; if (src && src.indexOf('moz-extension') !== -1) return;
if (src && src.indexOf('chrome-extension') !== -1) return; if (src && src.indexOf('chrome-extension') !== -1) return;
sendError({ type: 'uncaught', message: String(msg), source: src, line: line, col: col, stack: err && err.stack }); sendError({ type: 'uncaught', message: String(msg), source: src, line: line, col: col, stack: err && err.stack });
}; };
window.addEventListener('unhandledrejection', function(e) {
var msg = e.reason ? (e.reason.message || String(e.reason)) : 'Unhandled promise rejection'; window.addEventListener('unhandledrejection', function(e) {
sendError({ type: 'unhandledrejection', message: msg, stack: e.reason && e.reason.stack }); var msg = e.reason ? (e.reason.message || String(e.reason)) : 'Unhandled promise rejection';
}); sendError({ type: 'unhandledrejection', message: msg, stack: e.reason && e.reason.stack });
})(); });
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// --- COMPONENT LOADER (lazy-load tab HTML from /components/) --- // --- COMPONENT LOADER (lazy-load tab HTML from /components/) ---
var COMPONENT_VERSION = '7.1.3'; function getComponentVersion() {
try {
var script = document.currentScript || document.querySelector('script[src^="/js/app.js"]');
var version = script ? new URL(script.src, window.location.href).searchParams.get('v') : '';
return version || 'dev';
} catch(e) { return 'dev'; }
}
var COMPONENT_VERSION = getComponentVersion();
window.PEDSCRIBE_COMPONENT_VERSION = COMPONENT_VERSION;
var _componentCache = {}; var _componentCache = {};
var _componentLoading = {}; var _componentLoading = {};
@ -163,7 +171,6 @@ document.addEventListener('DOMContentLoaded', function() {
if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList(); if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList();
if (typeof renderAudioBackups === 'function') renderAudioBackups(); if (typeof renderAudioBackups === 'function') renderAudioBackups();
if (typeof loadDocuments === 'function') loadDocuments(); if (typeof loadDocuments === 'function') loadDocuments();
initBrowserWhisperSettings();
} }
if (e.detail && e.detail.tab === 'faq') { if (e.detail && e.detail.tab === 'faq') {
// Wire FAQ accordion after component loads // Wire FAQ accordion after component loads
@ -183,110 +190,10 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
// ── Browser Whisper settings UI ─────────────────────────── // --- MODEL SELECTORS ---
function initBrowserWhisperSettings() {
var chk = document.getElementById('browser-whisper-enabled');
var sel = document.getElementById('browser-whisper-model');
var pre = document.getElementById('btn-whisper-preload');
var stat = document.getElementById('browser-whisper-status');
var prog = document.getElementById('browser-whisper-progress');
var pt = document.getElementById('browser-whisper-progress-text');
var sec = document.getElementById('browser-whisper-section');
if (!chk) return;
var supported = typeof BrowserWhisper !== 'undefined' && BrowserWhisper.isSupported();
if (!supported) {
if (sec) sec.innerHTML += '<p style="color:var(--red);font-size:12px;margin:8px 0 0;">Not supported in this browser. Use Chrome or Edge.</p>';
if (chk) chk.disabled = true;
return;
}
// Restore saved state
chk.checked = BrowserWhisper.isEnabled();
sel.value = BrowserWhisper.getModel();
stat.textContent = chk.checked ? 'On — audio stays on device' : 'Off';
chk.addEventListener('change', function() {
BrowserWhisper.setEnabled(chk.checked);
stat.textContent = chk.checked ? 'On — audio stays on device' : 'Off';
if (chk.checked) {
BrowserWhisper.preload(function(file, pct) {
if (!prog || !pt) return;
if (pct >= 100) { prog.style.display = 'none'; return; }
prog.style.display = 'block';
pt.textContent = file + (pct > 0 ? ' ' + pct + '%' : '');
});
}
});
sel.addEventListener('change', function() {
BrowserWhisper.setModel(sel.value);
});
if (pre) {
pre.addEventListener('click', function(e) {
console.log('[BrowserWhisper] Pre-download button clicked!');
e.preventDefault();
if (!prog || !pt) {
console.error('[BrowserWhisper] Progress elements not found');
showToast('UI elements missing - check page load', 'error');
return;
}
if (!BrowserWhisper || !BrowserWhisper.isSupported()) {
console.error('[BrowserWhisper] Not supported');
showToast('Browser Whisper not supported in this browser', 'error');
return;
}
console.log('[BrowserWhisper] Starting preload...');
prog.style.display = 'block';
pt.textContent = 'Initializing...';
BrowserWhisper.setEnabled(true);
chk.checked = true;
stat.textContent = 'On — audio stays on device';
// Set timeout in case it gets stuck
var timeout = setTimeout(function() {
console.warn('[BrowserWhisper] 30s elapsed - still downloading, check Network tab');
showToast('Download in progress - check browser console', 'info');
}, 30000);
try {
BrowserWhisper.preload(function(file, pct) {
console.log('[BrowserWhisper] Progress:', file, pct + '%');
if (pct >= 100) {
clearTimeout(timeout);
prog.style.display = 'none';
showToast('Whisper model ready!', 'success');
return;
}
prog.style.display = 'block';
pt.textContent = file + (pct > 0 ? ' ' + pct + '%' : '');
});
} catch (err) {
clearTimeout(timeout);
console.error('[BrowserWhisper] Preload error:', err);
prog.style.display = 'none';
pt.textContent = '';
// Show CSP/network warning
var cspWarning = document.getElementById('browser-whisper-csp-warning');
if (cspWarning) cspWarning.style.display = 'block';
showToast('Download blocked by network/firewall. Server transcription will be used.', 'warning');
}
});
}
}
// --- MODEL SELECTOR ---
var modelSelect = document.getElementById('global-model-select');
var costBadge = document.getElementById('model-cost-badge');
window._currentModels = []; window._currentModels = [];
window._currentProvider = 'openrouter'; window._currentProvider = 'openrouter';
window._defaultModelId = '';
fetch('/api/models') fetch('/api/models')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
@ -302,33 +209,26 @@ document.addEventListener('DOMContentLoaded', function() {
opt.textContent = m.name; opt.textContent = m.name;
selectEl.appendChild(opt); selectEl.appendChild(opt);
}); });
if (window._defaultModelId && !Array.prototype.some.call(selectEl.options, function(opt) { return opt.value === window._defaultModelId; })) {
var saved = document.createElement('option');
saved.value = window._defaultModelId;
saved.textContent = window._defaultModelId + ' (saved default)';
selectEl.appendChild(saved);
}
if (window._defaultModelId) selectEl.value = window._defaultModelId;
} }
// Determine default model (admin override or first model) // Determine default model (admin override or first model)
var defaultModelId = data.defaultModel || (window._currentModels.length > 0 ? window._currentModels[0].id : ''); var defaultModelId = data.defaultModel || (window._currentModels.length > 0 ? window._currentModels[0].id : '');
window._defaultModelId = defaultModelId;
if (modelSelect && window._currentModels.length > 0) {
window._buildModelOptions(modelSelect);
// Select the admin-configured default
if (defaultModelId) modelSelect.value = defaultModelId;
if (costBadge) costBadge.textContent = '';
}
// Populate all per-tab model selectors already in DOM // Populate all per-tab model selectors already in DOM
document.querySelectorAll('.tab-model-select').forEach(function(sel) { document.querySelectorAll('.tab-model-select').forEach(function(sel) {
window._buildModelOptions(sel); window._buildModelOptions(sel);
if (defaultModelId) sel.value = defaultModelId;
}); });
}) })
.catch(function(err) { console.warn('Models load failed:', err); }); .catch(function(err) { console.warn('Models load failed:', err); });
if (modelSelect) {
modelSelect.addEventListener('change', function() {
var m = window._currentModels.find(function(x) { return x.id === modelSelect.value; });
showToast('Model: ' + modelSelect.value.split('/').pop(), 'info');
});
}
console.log('✅ App.js DOM ready'); console.log('✅ App.js DOM ready');
}); // end DOMContentLoaded }); // end DOMContentLoaded
@ -410,15 +310,13 @@ function loadAnnouncement() {
} }
// Close button — dismiss for this page view only (reappears on refresh/re-login) // Close button — dismiss for this page view only (reappears on refresh/re-login)
(function() { var closeBtn = document.getElementById('announcement-close');
var closeBtn = document.getElementById('announcement-close'); if (closeBtn) {
if (closeBtn) { closeBtn.addEventListener('click', function() {
closeBtn.addEventListener('click', function() { var banner = document.getElementById('announcement-banner');
var banner = document.getElementById('announcement-banner'); if (banner) banner.classList.add('hidden');
if (banner) banner.classList.add('hidden'); });
}); }
}
})();
function getSelectedModel() { function getSelectedModel() {
// Prefer the active tab's own model selector if present // Prefer the active tab's own model selector if present
@ -427,8 +325,7 @@ function getSelectedModel() {
var tabSel = activeTab.querySelector('.tab-model-select'); var tabSel = activeTab.querySelector('.tab-model-select');
if (tabSel && tabSel.value) return tabSel.value; if (tabSel && tabSel.value) return tabSel.value;
} }
var sel = document.getElementById('global-model-select'); return undefined;
return sel ? sel.value : 'google/gemini-2.5-flash';
} }
function showLoading(text) { function showLoading(text) {
@ -703,9 +600,19 @@ window.nativeStopRecordingService = function() {
try { if (window.NativeRecording) window.NativeRecording.stopForegroundService(); } catch(e) {} try { if (window.NativeRecording) window.NativeRecording.stopForegroundService(); } catch(e) {}
}; };
// Keep screen awake during recording (Capacitor KeepAwake or InsomniaCap) // Keep screen awake during recording.
//
// Prefer the NativeRecording bridge (addJavascriptInterface, so it is present
// on the remote origin the launcher navigates to). The Capacitor KeepAwake
// plugin is kept as a fallback but is NOT installed in this project — relying
// on it alone meant this function silently did nothing and the screen slept
// mid-recording, killing the MediaRecorder.
window.nativeKeepAwake = function(on) { window.nativeKeepAwake = function(on) {
try { try {
if (window.NativeRecording && typeof window.NativeRecording.keepAwake === 'function') {
window.NativeRecording.keepAwake(!!on);
return;
}
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake) { if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake) {
if (on) window.Capacitor.Plugins.KeepAwake.keepAwake(); if (on) window.Capacitor.Plugins.KeepAwake.keepAwake();
else window.Capacitor.Plugins.KeepAwake.allowSleep(); else window.Capacitor.Plugins.KeepAwake.allowSleep();
@ -738,22 +645,6 @@ function checkTranscribeStatus() {
} }
function transcribeAudio(blob) { function transcribeAudio(blob) {
// Browser Whisper — local, zero network, HIPAA-safe
if (typeof BrowserWhisper !== 'undefined' && BrowserWhisper.isEnabled()) {
var startTime = Date.now();
return BrowserWhisper.transcribe(blob)
.then(function(text) {
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
showToast('Transcribed locally (' + elapsed + 's)', 'success');
window._lastAudioBackupId = null;
return { success: true, text: text, provider: 'browser-whisper' };
})
.catch(function(err) {
console.warn('[BrowserWhisper] Failed:', err.message, '— falling back to server');
if (typeof saveAudioBackup === 'function') saveAudioBackup(blob, 'browser-whisper-failed').catch(function() {});
return _serverTranscribe(blob);
});
}
return _serverTranscribe(blob); return _serverTranscribe(blob);
} }
@ -862,8 +753,8 @@ function suggestBillingCodes(outputElementId, noteText, noteType, patientAge, vi
if (data.emLevel) { if (data.emLevel) {
html += '<div class="billing-codes-section"><div class="billing-codes-label">E/M Assessment</div>'; html += '<div class="billing-codes-section"><div class="billing-codes-label">E/M Assessment</div>';
html += '<span class="billing-code-chip em">Level ' + data.emLevel.level + '</span>'; html += '<span class="billing-code-chip em">Level ' + escHtml(data.emLevel.level) + '</span>';
html += '<span style="font-size:11px;color:var(--g500);margin-left:6px;">MDM: ' + data.emLevel.complexity + ' | ' + data.emLevel.diagnosisCount + ' dx | ' + data.emLevel.rosCount + ' ROS | ' + data.emLevel.peCount + ' PE</span>'; html += '<span style="font-size:11px;color:var(--g500);margin-left:6px;">MDM: ' + escHtml(data.emLevel.complexity) + ' | ' + escHtml(data.emLevel.diagnosisCount) + ' dx | ' + escHtml(data.emLevel.rosCount) + ' ROS | ' + escHtml(data.emLevel.peCount) + ' PE</span>';
html += '</div>'; html += '</div>';
} }
@ -944,6 +835,112 @@ function suggestDontMiss(outputElementId, noteText, noteType, patientAge, chiefC
}); });
} }
// ── Patient education handout helper ────────────────────────
// Adds a reusable "Handout" action beside generated clinical notes. The actual
// handout is generated only when the physician clicks Generate in the panel.
function attachPatientEducation(outputElementId, opts) {
var outputEl = document.getElementById(outputElementId);
if (!outputEl) return;
opts = opts || {};
var card = outputEl.closest('.card, .output-card');
if (!card) return;
var actions = card.querySelector('.output-actions');
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
if (actions && !document.getElementById(prefix + '-patient-ed-btn')) {
var btn = document.createElement('button');
btn.id = prefix + '-patient-ed-btn';
btn.className = 'btn-sm btn-ghost';
btn.type = 'button';
btn.innerHTML = '<i class="fas fa-person-breastfeeding"></i> Handout';
btn.addEventListener('click', function() {
var panel = ensurePatientEducationPanel(outputElementId, opts);
panel.classList.remove('hidden');
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
actions.appendChild(btn);
}
}
function ensurePatientEducationPanel(outputElementId, opts) {
var outputEl = document.getElementById(outputElementId);
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
var panelId = prefix + '-patient-ed';
var panel = document.getElementById(panelId);
if (panel) return panel;
panel = document.createElement('div');
panel.id = panelId;
panel.className = 'card patient-ed-card hidden';
panel.style.cssText = 'margin-top:10px;border-left:3px solid #0ea5e9;';
panel.innerHTML =
'<div class="card-header output-header">' +
'<h3><i class="fas fa-person-breastfeeding" style="color:#0ea5e9;"></i> Patient Education Handout</h3>' +
'<div class="output-actions">' +
'<button class="btn-sm btn-primary" id="' + prefix + '-patient-ed-generate" type="button"><i class="fas fa-wand-magic-sparkles"></i> Generate</button>' +
'</div>' +
'</div>' +
'<div style="padding:10px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;align-items:end;">' +
'<div class="demo-field"><label>Parent language</label><select id="' + prefix + '-patient-ed-language">' +
'<option>English</option><option>Spanish</option><option>French</option><option>Arabic</option><option>Haitian Creole</option><option>Chinese</option><option>Russian</option><option>Portuguese</option>' +
'</select></div>' +
'<div class="demo-field"><label>Diagnosis/context</label><input type="text" id="' + prefix + '-patient-ed-diagnosis" placeholder="Optional: diagnosis to emphasize"></div>' +
'<div class="demo-field"><label>Medications</label><input type="text" id="' + prefix + '-patient-ed-meds" placeholder="Optional: meds/doses from plan"></div>' +
'</div>' +
'<div id="' + prefix + '-patient-ed-text" class="output-text" contenteditable="true" style="margin:0 16px 12px;min-height:120px;" data-placeholder="Generated parent handout appears here..."></div>' +
'<div style="padding:0 16px 12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
'<button class="btn-sm btn-primary" data-action="copy" data-target="' + prefix + '-patient-ed-text"><i class="fas fa-copy"></i> Copy</button>' +
'<span style="font-size:11px;color:var(--g500);">Parent-facing draft. Verify before sharing.</span>' +
'</div>';
outputEl.parentNode.insertBefore(panel, outputEl.nextSibling);
var gen = panel.querySelector('#' + prefix + '-patient-ed-generate');
if (gen) gen.addEventListener('click', function() { generatePatientEducation(outputElementId, opts); });
return panel;
}
function generatePatientEducation(outputElementId, opts) {
var outputEl = document.getElementById(outputElementId);
if (!outputEl) return;
opts = opts || {};
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
var handoutEl = document.getElementById(prefix + '-patient-ed-text');
var langEl = document.getElementById(prefix + '-patient-ed-language');
var dxEl = document.getElementById(prefix + '-patient-ed-diagnosis');
var medsEl = document.getElementById(prefix + '-patient-ed-meds');
var noteText = (outputEl.innerText || outputEl.textContent || '').trim();
if (!noteText) { showToast('No note for handout', 'error'); return; }
if (handoutEl) handoutEl.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating parent handout...';
fetch('/api/patient-education', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
noteText: noteText,
diagnosis: dxEl ? dxEl.value : '',
medications: medsEl ? medsEl.value : '',
patientAge: opts.patientAge || '',
language: langEl ? langEl.value : 'English',
readingLevel: '6th grade plain language',
model: getSelectedModel()
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) {
if (handoutEl) handoutEl.textContent = '';
showToast(data.error || 'Handout generation failed', 'error');
return;
}
setOutputText(handoutEl, data.handout || '');
showToast('Patient handout generated', 'success');
})
.catch(function(err) {
if (handoutEl) handoutEl.textContent = '';
showToast(err.message || 'Handout generation failed', 'error');
});
}
function refineDocument(outputElementId, inputElementId) { function refineDocument(outputElementId, inputElementId) {
var doc = document.getElementById(outputElementId); var doc = document.getElementById(outputElementId);
var input = document.getElementById(inputElementId); var input = document.getElementById(inputElementId);
@ -1006,6 +1003,7 @@ function exportToNextcloud(elementId, docType) {
} }
function createSpeechRecognition() { function createSpeechRecognition() {
if (window.WebSpeechRecognition && !window.WebSpeechRecognition.isEnabled()) return null;
if (!(window.SpeechRecognition || window.webkitSpeechRecognition)) return null; if (!(window.SpeechRecognition || window.webkitSpeechRecognition)) return null;
var SR = window.SpeechRecognition || window.webkitSpeechRecognition; var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
var rec = new SR(); var rec = new SR();
@ -1040,7 +1038,7 @@ function deduplicateFinal(newText, existingText) {
// PWA Service Worker // PWA Service Worker
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function() {}); navigator.serviceWorker.register('/sw.js?v=' + encodeURIComponent(window.PEDSCRIBE_COMPONENT_VERSION || 'dev')).catch(function() {});
} }
console.log('✅ App.js loaded'); console.log('✅ App.js loaded');

View file

@ -0,0 +1,94 @@
function authHeaders() {
return window.getAuthHeaders ? window.getAuthHeaders() : { 'Content-Type': 'application/json' };
}
function parseJsonWithStatus(response) {
return response.json().catch(function () { return {}; }).then(function(data) {
data._status = response.status;
return data;
});
}
export function fetchAssistantStatus() {
return fetch('/api/clinical-assistant/status', { headers: authHeaders(), credentials: 'same-origin' })
.then(function(r) { return r.json(); });
}
export function fetchAssistantExamples() {
return fetch('/api/clinical-assistant/examples', { headers: authHeaders(), credentials: 'same-origin' })
.then(function(r) { return r.json(); });
}
export function openAssistantStream(payload, options) {
options = options || {};
return fetch('/api/clinical-assistant/chat/stream', {
method: 'POST',
headers: authHeaders(),
credentials: 'same-origin',
signal: options.signal,
body: JSON.stringify(payload)
});
}
export function fetchAssistantChat(payload, options) {
options = options || {};
return fetch('/api/clinical-assistant/chat', {
method: 'POST',
headers: authHeaders(),
credentials: 'same-origin',
signal: options.signal,
body: JSON.stringify(payload)
}).then(parseJsonWithStatus);
}
export function requestAssistantImage(prompt) {
return fetch('/api/clinical-assistant/image', {
method: 'POST',
headers: authHeaders(),
credentials: 'same-origin',
body: JSON.stringify({ prompt: prompt })
}).then(function(r) { return r.json(); });
}
export function startAssistantImageJob(prompt) {
return fetch('/api/clinical-assistant/image/jobs', {
method: 'POST',
headers: authHeaders(),
credentials: 'same-origin',
body: JSON.stringify({ prompt: prompt })
}).then(function(r) { return r.json(); });
}
export function fetchAssistantImageJob(jobId) {
return fetch('/api/clinical-assistant/image/jobs/' + encodeURIComponent(jobId), {
headers: authHeaders(),
credentials: 'same-origin'
}).then(function(r) { return r.json(); });
}
export function saveAssistantChat(payload) {
return fetch('/api/clinical-assistant/chats', {
method: 'POST',
headers: authHeaders(),
credentials: 'same-origin',
body: JSON.stringify(payload)
}).then(parseJsonWithStatus);
}
export function fetchSavedAssistantChats() {
return fetch('/api/clinical-assistant/chats', { headers: authHeaders(), credentials: 'same-origin' })
.then(function(r) { return r.json(); });
}
export function fetchSavedAssistantChat(id) {
return fetch('/api/clinical-assistant/chats/' + encodeURIComponent(id), { headers: authHeaders(), credentials: 'same-origin' })
.then(parseJsonWithStatus);
}
export function deleteSavedAssistantChat(id) {
return fetch('/api/clinical-assistant/chats/' + encodeURIComponent(id), {
method: 'DELETE',
headers: authHeaders(),
credentials: 'same-origin'
}).then(function(r) { return r.json(); });
}

Some files were not shown because too many files have changed in this diff Show more