Compare commits

..

537 commits

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
Daniel Onyejesi
805d0fea55 Expand DEVELOPER_GUIDE with detailed architecture, DB layer, file descriptions
- Added request flow walkthrough, AI provider selection logic
- Documented database layer (raw pg, no ORM) with helper methods
- Expanded directory structure with detailed per-file descriptions
- Expanded frontend JS descriptions (what each file does)
- Rewrote PDF section with current 50k char limit, supported types
- Updated version history through v6.0
2026-03-24 19:30:01 -04:00
Daniel Onyejesi
1b1fe535da Increase PDF/doc context to 50k chars, raise maxTokens ceiling to 8k
Supports large PDFs (~30 pages) from upload and Nextcloud.
maxTokens is a ceiling, not a target — short topics still get short responses.
2026-03-24 19:22:51 -04:00
Daniel Onyejesi
feb47b5e79 Re-add vendor model Opus 4.6, update DEVELOPER_GUIDE with logs/Bedrock docs
- Re-added Opus 4.6 (JSON sanitizer now handles its output)
- Added Logs & Debugging section with docker logs commands and prefixes
- Added Bedrock Model Notes (inference profiles, maxTokens, JSON sanitization)
- Updated version history through v5.8
- Updated rate limiting docs, build/push process, current image tag
2026-03-24 19:14:00 -04:00
Daniel Onyejesi
5e3c95d163 Fix JSON parse for Sonnet 4.6: escape literal newlines in strings
Sonnet 4.6 outputs literal newline characters inside JSON string
values (e.g. in HTML body field) instead of \n escape sequences.
Added sanitizeJsonString that walks the string character by character
and escapes unescaped control chars inside quoted values.
2026-03-24 19:01:16 -04:00
Daniel Onyejesi
4b62d073f7 Remove vendor model Opus 4.6 (returns invalid JSON via Bedrock) 2026-03-24 18:43:28 -04:00
Daniel Onyejesi
993e3a112f Re-add Qwen3 235B (works in us-east-1 despite docs) 2026-03-24 18:35:51 -04:00
Daniel Onyejesi
986ee76489 Comprehensive Bedrock fix: inference profiles + maxTokens clamping
- ALL models that need inference profiles now use us. prefix IDs:
  Anthropic (all 8), Nova (3), Meta Llama (3), DeepSeek R1, Writer
- Models with low max output tokens (Cohere 4096, Jamba 4096) now
  have maxOut field; callBedrock clamps maxTokens automatically
- Removed Qwen3 235B (no us-east-1 support, no inference profile)
- Added better debug logging for response block structure
2026-03-24 18:26:43 -04:00
Daniel Onyejesi
6932dddbc1 Fix Anthropic models: use cross-region inference profiles (us. prefix)
Opus 4.6, Opus 4.5, Sonnet 4.6, Haiku 4.5 do NOT support direct
on-demand invocation — they require cross-region inference profile IDs
(us.anthropic.* prefix). Updated all affected bedrockIds. Also fixed
isAnthropic check to handle us.anthropic.* prefix.

Sonnet 4.5, Sonnet 4, 3.5 Haiku also updated to use profiles for
consistency (profiles work everywhere, direct IDs are limited).
2026-03-24 18:17:36 -04:00
Daniel Onyejesi
c2646f0384 Fix Opus 4.6 thinking blocks, re-add Amazon Nova models
- Opus 4.6 returns multiple content blocks (thinking + text); was only
  reading content[0] which was the thinking block (just '{')
- Now iterates all blocks and extracts only type:'text' blocks
- Added debug logging for Bedrock response structure
- Re-added Nova Pro, Nova Lite, Nova Micro with correct region metadata
2026-03-24 18:05:27 -04:00
Daniel Onyejesi
4dc14aba0d Fix AI invalid JSON: add system prompt + robust JSON extraction
Opus 4.6 and other large models add preamble/thinking text before
JSON output. Fix: add system message enforcing JSON-only, strip
leading text before first {, trim trailing text after last }, and
log raw output on parse failure for debugging.
2026-03-24 17:52:49 -04:00
Daniel Onyejesi
205822a496 Fix Bedrock models: remove Nova (no on-demand), add region filtering
- Removed Amazon Nova models (not available for on-demand invocation)
- Replaced DeepSeek V3 with V3.2 (correct model ID)
- Removed Mistral Large 24.07 (us-west-2 only), added Magistral Small
- Added vendor model Opus 4.1, Writer Palmyra X5, Qwen3, Command R
- Each model now has region metadata; getAvailableModels() filters
  to only show models available in the configured AWS_BEDROCK_REGION
- Fixes "on-demand throughput not allowed" and region mismatch errors
2026-03-24 17:45:36 -04:00
Daniel Onyejesi
230a73be3a Add resend verification link on login + rate limit (3/15min) 2026-03-24 14:14:42 -04:00
Daniel Onyejesi
d073f398d8 Fix CMS button clickability, replace AI topic input with context box
- Add type="button" to all CMS action buttons to prevent default submit
- Change overflow:hidden to overflow:visible on .cms-main to prevent clipping
- Add z-index to .cms-editor-actions for reliable click targeting
- Replace AI "Topic" text input with a descriptive textarea context box
  so users can tell AI what to generate (AI creates its own title)
- Rename AI tab from "By Topic" to "Describe Content"
2026-03-24 11:52:02 -04:00
Daniel Onyejesi
3ef16f6c6a Fix login screen not showing on sign-out
Remove !important from #auth-screen CSS so the inline style set by
exitApp() can override it and display the login form after logout.
2026-03-24 11:24:26 -04:00
Daniel Onyejesi
747fc3322a Fix Learning Hub search and content viewer isolation
Search (was completely broken):
- wireSearch() registered inside tabChanged for 'learning' tab — runs
  after component HTML is in the DOM (lh-search existed but listener
  was never attached because element was null at IIFE load time)
- Proper debounced search with 350ms delay, Escape key to clear
- Search results show count, 'X results for "query"', Clear Search button
- Empty state with helpful message; loading state while fetching

Content viewer showing feed items beneath it (CSS cascade bug):
- .lh-feed{display:flex} and .lh-category-bar{display:flex} defined
  at CSS lines 513/507 — after .hidden{display:none} at line 242
  So classList.add('hidden') was silently overridden
- showFeed() and showViewer() now use element.style.display with
  explicit values ('flex'/'block'/'none') which always win over class rules
- viewer started hidden via class="hidden"; now shown with style.display='block'
2026-03-24 03:13:22 -04:00
Daniel Onyejesi
ac8cc38e3b Render presentations as slides in Learning Hub viewer
- New GET /api/learning/content/:slug/slides (authMiddleware only)
  Renders Marp markdown via marp-core, returns {css, slides[]} for
  any authenticated user — no moderator access required

- showViewer: when content_type === 'presentation', hides the body card
  and shows a presentation card with title, slide count, and View Slides button
  Auto-opens the slide modal immediately on load; button allows re-opening

- openSlidesFromSlug(): fetches slides from user-accessible endpoint
  then opens the existing slide preview modal (arrow keys, swipe, dots)

- Viewer already correctly hides feed/categories/search when viewing content;
  "Back to Feed" button returns to the main list
2026-03-24 03:04:14 -04:00
Daniel Onyejesi
a6fd59fab4 Update DEVELOPER_GUIDE: PDF/embedding rationale, scalability, security notes
- Section 17: PDF uploads — why no vector embeddings needed (single-file
  generation uses full-text extraction which is better for this use case;
  explains when embeddings would be appropriate and how to increase truncation limit)
- Section 18: Scalability — what already scales, bottlenecks (rate limiting,
  file uploads), horizontal scaling pattern with Redis + nginx, cloud options
- Section 19: Security — localStorage vs httpOnly cookie decision documented
  with rationale; existing XSS protections listed; path to httpOnly if needed
- Section 15: Version history updated and moved below new sections
- Version bumped to 3.19, current Docker tag updated
2026-03-24 02:57:04 -04:00
Daniel Onyejesi
82104a031c Update DEVELOPER_GUIDE to v3.19 with version history and current Docker tag 2026-03-24 02:51:59 -04:00
Daniel Onyejesi
01f6a38ee2 Fix login flash, quiz in presentations, revert viewer body, feed labels
Login flash:
- Auth screen now hidden by CSS default (#auth-screen{display:none !important})
- auth.js shows it only when there is no valid token or token is expired
- No more flash of login page on refresh for logged-in users

Presentation quiz option (AI Generate panel):
- Presentation type now shows optional quiz question toggle (same as article)
- Backend: when questionCount>0, returns JSON {marpMarkdown, questions[]}
  instead of plain Marp markdown
- applyAiContent fills both the Marp textarea and question blocks for presentations

Viewer revert:
- Removed the viewer presentation card change (user wants markdown as-is)
- Removed dangling viewer button wiring

Feed/CMS list:
- Presentation type shows correct icon (fa-presentation-screen) and
  label "Slides" in the CMS content list
2026-03-24 02:50:31 -04:00
Daniel Onyejesi
23d39dec65 Fix WebDAV selection, pdf-parse, prompt(), CSP, security issues 1/2/4/5
pdf-parse (was v2, broken API):
- Downgraded to v1.1.1 — default export is a function again
- pdfParse is not a function error fixed

WebDAV file selection (style.display bug, same cascade issue):
- lh-ai-webdav-selected had inline style="display:flex" always visible
- selectWebdavFile() / deselectWebdavFile() now use element.style.display
- On select: browser div hides, selected indicator shows with file name + X
- On deselect (X): indicator hides, browser shows again

Topic context for Upload and Nextcloud tabs:
- Both tabs now have optional "Topic / context" field
- Sent to backend as 'topic' param to help AI focus the generated content

Inline refine bar (replaces window.prompt):
- Refine Body button shows/hides an amber inline input bar below generate buttons
- Enter instructions, press Apply — no browser dialog, works in all contexts

CSP: remove unsafe-inline from scriptSrc (security issue #2):
- Converted all 26 onclick= handlers in component HTML files to
  data-action / data-target / data-label attributes
- Added delegated click handler in app.js for copy/speak/nc-export actions
- script-src now 'self' only; script-src-attr 'none'

WebDAV path endpoint (security issue #5):
- New POST /api/user/webdav-path (authMiddleware only, not moderator)
- nextcloud.js updated to use new endpoint

nodemailer: already at 6.10.1 (vulnerability fixed).
2026-03-24 02:28:43 -04:00
Daniel Onyejesi
9a584bcc8a Fix AI panel options: use style.display, redesign quiz card
Bug: classList.toggle('hidden') was silently ignored because
.lh-ai-opt-field{display:flex} is defined at CSS line 758, after
.hidden{display:none} at line 242 — same specificity, later rule wins.
Fix: updateAiOptions() now uses element.style.display directly (inline
styles always override class rules).

Also fix initial state: lh-ai-slides-field now uses style="display:none"
instead of class="hidden" so it's correctly hidden on load.

Panel syncs content type from editor on open (editorType → ctype select
→ updateAiOptions called immediately).

Per type:
- Quiz: word count hidden, slides hidden, quiz card visible (no toggle)
- Presentation: word count hidden, slides visible, quiz card hidden
- Article/Pearl: word count visible, slides hidden, quiz card with toggle

Quiz card redesign: gradient blue/indigo background, white header with
icon, clean body — replaces the plain grey rectangle.
2026-03-24 02:07:56 -04:00
Daniel Onyejesi
d78b79714c Add DEVELOPER_GUIDE.md — full handover documentation
Covers: architecture, directory structure, all env vars, full DB schema,
auth system, every API endpoint with auth level, frontend tab loading
pattern (critical: tabChanged not DOMContentLoaded), AI integration,
Learning Hub content types, deployment commands, security notes,
admin password reset via Docker console, and how to add new features.
2026-03-24 02:02:31 -04:00
Daniel Onyejesi
21c1c5ea6d Revert auth to localStorage tokens, fix slide padding, AI panel options
REVERT: httpOnly cookie auth removed — reverts to JWT in localStorage.
The cookie implementation broke the login page (always-hidden auth screen).
localStorage token approach is restored fully:
- Login/register return token in JSON body
- getAuthHeaders() sends Authorization: Bearer header
- Session check reads localStorage token on load
- clearSession() removes localStorage token

Fix: slide preview padding — section elements now have 40px 48px padding
so text is not flush against the frame edges.

Kept from v3.14: AI panel context-aware options (word count, slide count,
quiz question toggle), delete confirmation wording per content type.
2026-03-24 01:57:59 -04:00
Daniel Onyejesi
9d63625987 AI panel context-aware options, delete wording, httpOnly cookie auth
AI Generate panel:
- Content type synced from editor on open
- Article/Pearl: optional word count field
- Presentation: optional slide count field
- Questions row: checkbox toggle for article/pearl (optional), always on for quiz,
  hidden for presentation; count input shown only when enabled
- Backend: passes wordCount/slideCount to prompt; questionCount=0 skips questions

Delete confirmation:
- Wording built dynamically from content type
- Presentation: no mention of questions
- Quiz: "All quiz questions will be permanently removed"
- Article/pearl: "Any quiz questions attached will also be removed"

Auth refactor — JWT → httpOnly cookie:
- Backend: setAuthCookie() sets ped_auth as httpOnly, Secure, SameSite=Lax, 7d
- Backend: clearAuthCookie() on POST /api/auth/logout (new endpoint)
- Backend: auth middleware reads ped_auth cookie first, falls back to Bearer header
- Login/register: no longer return token in JSON body; cookie is the session
- Frontend: getAuthHeaders() returns only Content-Type (no Authorization header)
- Frontend: session check uses /api/auth/me via cookie (no localStorage token)
- Frontend: clearSession() calls /api/auth/logout to clear cookie server-side
- Frontend: no token in localStorage; window.AUTH_TOKEN removed
- All FormData fetches use credentials:'same-origin' (cookie sent automatically)
- index.html: optimistic auth-screen hide (removed localStorage token check)

Tested: login, logout, /api/auth/me, admin routes, FormData uploads, cookie httpOnly
flag, 401 without cookie, cookie cleared on logout.
2026-03-24 01:46:07 -04:00
Daniel Onyejesi
10620a3152 Fix delete bar always visible, slide preview modal, Marp textarea hint
Delete bar bug:
- Root cause: .lh-delete-confirm-bar{display:flex} defined after .hidden{display:none}
  so flex overrode hidden (no !important on global .hidden rule)
- Fix: remove display:flex from base rule, use .lh-delete-confirm-bar:not(.hidden){display:flex}
  so hidden class always wins regardless of CSS order

Slide preview (replace window.open + document.write approach):
- Backend /preview-slides now returns JSON: {css, slides[]} (individual <section> HTML)
- In-page full-screen modal with dark overlay, white slide frame
- Prev/Next arrow buttons, dot indicators, slide counter
- Keyboard navigation (arrow keys, Escape to close)
- Touch/swipe support for mobile
- Overlay click closes modal
- No popups required, works in all browsers

Marp textarea:
- Placeholder updated to guide users: "Click AI Generate above to create slides"
- Makes it clear AI is the primary authoring path; textarea for review/edits
2026-03-24 01:13:55 -04:00
Daniel Onyejesi
dc40e94749 Delete confirm inline bar, lighter login, Presentation type with Marp+PPTX
Delete confirmation:
- Replaced browser confirm() popup with inline red warning bar in editor
- Shows content title, Cancel and Delete buttons inline in the editor header
- Category delete uses double-click pattern with toast feedback

Login screen:
- Changed from dark blue/purple gradient to light gray background (var(--g50))
- Card now matches 404 page style: white, subtle blue shadow, indigo border

Presentation content type:
- New 'Presentation' button in CMS toolbar (green style)
- Marp markdown textarea editor (dark code editor style) shown when type=presentation
- Body/Tiptap editor hidden; Marp section shown (toggleEditorMode)
- Preview button: renders Marp HTML via @marp-team/marp-core, opens in new tab
- Download PPTX: pure-JS pptxgenjs (no Chromium), parses slides, exports .pptx
- AI generate: returns Marp markdown directly when contentType=presentation
- Body column stores Marp markdown for presentations

Backend:
- POST /api/admin/learning/generate-pptx — pptxgenjs PPTX from Marp markdown
- POST /api/admin/learning/preview-slides — Marp HTML preview

GitHub repo set to private via API.
2026-03-24 00:59:36 -04:00
Daniel Onyejesi
e16e47f634 Add AI content generation to Learning Hub CMS
Backend (src/routes/learningAI.js):
- POST /api/admin/learning/ai-generate
  * Accepts: topic text, uploaded file (PDF/TXT/MD/HTML), or Nextcloud WebDAV path
  * Extracts text from PDFs via pdf-parse; plain text read directly
  * Fetches WebDAV files using stored Nextcloud credentials
  * Prompts AI to return structured JSON: title, subject, body (HTML), questions[]
  * Strips code fences, falls back to regex JSON extraction if needed
- POST /api/admin/learning/ai-refine — refine existing body with instructions
- GET  /api/admin/learning/webdav-browse — PROPFIND-based Nextcloud file browser
- POST /api/admin/learning/webdav-path — save user's default browse path

Frontend:
- AI Generate button in CMS editor header (gradient purple→blue)
- Panel with 3 source tabs: By Topic / Upload File / Nextcloud
- Drag-and-drop file dropzone with DataTransfer API support
- WebDAV file browser: navigate folders, select files, breadcrumb path
- Options: question count, content type (article/quiz/pearl), model selector, instructions
- Refine Current Body: prompt-driven AI rewrite of existing content
- Generated content auto-fills title, subject, type, Tiptap body, quiz questions

Settings:
- Nextcloud section: "Learning Hub Default Browse Path" field (shown when connected)
- Saves to webdav_learning_path column via /api/admin/learning/webdav-path

DB: ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT
2026-03-24 00:17:28 -04:00
Daniel Onyejesi
a3277f352e Add proper 404 handling and custom error page
Only serve index.html for GET /; all other unknown paths return HTTP 404
with a custom branded error page (stethoscope SVG illustration, matches
app design, links back to /).
2026-03-23 23:47:50 -04:00
Daniel Onyejesi
6633763833 Add tiptap and build dependencies to package.json 2026-03-23 23:40:58 -04:00
Daniel Onyejesi
aae75b678f Replace Quill with Tiptap rich text editor
- Bundle Tiptap 2 (StarterKit, Link, Underline) with esbuild into
  public/vendor/tiptap.bundle.js — fully self-hosted, no CDN needed
- Remove Quill (quill.min.js, quill.snow.css, patchQuillTooltip hack)
- Headless Tiptap editor: custom toolbar (bold/italic/underline/strike,
  headings, lists, blockquote, code, link) with active-state highlighting
- Link insertion: inline link bar below toolbar (no popup, no tooltip)
  — type URL, press Apply or Enter; Remove button to unset
- Mini editor for question text and option text (same toggle UX)
- ProseMirror content styles: headings, lists, blockquote, code, links
2026-03-23 23:32:41 -04:00
Daniel Onyejesi
8aa1c5d0f7 Fix Quill link tooltip positioning and registration flash
Quill tooltip: move tooltip element to document.body via patchQuillTooltip()
so overflow:hidden on .cms-main cannot clip it. Recalculates position from
container-relative to viewport (fixed) coordinates with viewport clamping.
Removes overflow:hidden from quill wrappers; applies border-radius to toolbar/container.

Registration: hide #show-register by default in HTML; reveal only after
registration-status fetch confirms enabled=true. Endpoint already returns 403
when disabled — no backend change needed.
2026-03-23 23:09:21 -04:00
Daniel Onyejesi
d0299f5284 Fix vaccine/catchup tabs and serve Quill locally
- wellVisit.js now handles tabChanged for vaxschedule and catchup tabs
  so renderFullSchedule() and renderCatchUp() fire when those tabs open
- Replace Quill CDN with local /vendor/quill.min.js + quill.snow.css
  (CDN was unreachable in local/offline environments)
2026-03-23 22:59:47 -04:00
Daniel Onyejesi
d8ba336451 Add Quill rich text editor, multi-select questions, per-field rich text toggle
- Replace textarea+toolbar with Quill.js (free, MIT) for body editor
  - Full WYSIWYG: headings, bold/italic, lists, blockquote, code, link (inline, no popup)
- Rich Text toggle button on each question text field and option text field
  - Plain textarea by default; toggle activates mini Quill editor per field
- New 'Multiple Select' question type: checkboxes, all correct must be chosen
  - Backend scoring: allCorrectChosen && noWrongChosen
  - Results show all correct options highlighted, wrong selections flagged
- quiz display uses sanitizeHtml for question/option text (supports formatted HTML)
- sanitizeHtml allows Quill class attrs (ql-indent, etc.)
2026-03-23 22:37:29 -04:00
Daniel Onyejesi
868afcda20 Fix module init: defer all tab scripts until component HTML is loaded
All JS modules were running immediately (IIFE/DOMContentLoaded) but
tab HTML is lazy-loaded from /components/*.html, causing null errors
on every addEventListener call. Each module now listens for tabChanged
with its tab name and initializes once after the DOM is ready.

Also: quiz question/explanation boxes changed to textarea for
multi-line support; quiz display uses sanitizeHtml() so formatted
text (bold, lists, etc.) renders correctly.
2026-03-23 22:03:54 -04:00
Daniel Onyejesi
ca68177abb Fix line breaks in AI output: use innerHTML instead of textContent
Adds setOutputText() helper that escapes HTML and converts \n to <br>,
applied to all AI-generated note outputs so line breaks are preserved
in contenteditable divs. Also cleans up CSS for quiz option markers.
2026-03-23 21:17:50 -04:00
Daniel Onyejesi
7eb72cee78 Fix loading race condition, improve quiz UX, editor toolbar, settings page
- Fix: component loader now fires tabChanged AFTER HTML is in DOM
  (fixes CMS/settings stuck at loading)
- CMS: separate "New Article", "New Quiz", "New Pearl" buttons
- CMS: editor toolbar with formatting buttons (bold, italic, headings,
  lists, links, tables, quotes, code, hr)
- CMS: quiz questions have numbered headers (Q1, Q2...), larger inputs,
  clear "check correct answer" labels
- CMS: validation prevents saving questions without correct answer marked
- Quiz UX: large card-style question boxes with gradient number badges,
  bigger option buttons with hover effects and selection feedback,
  checkmark/cross icons on results
- Settings: improved card spacing, input styling, section headers with
  bottom borders for visual hierarchy
- Settings: updated compliance section (removed HIPAA references, added
  BAA/institution guidelines language)
2026-03-23 20:37:35 -04:00
Daniel Onyejesi
28464308ba Dedicated CMS page, responsive email templates, security fixes in auth
- New dedicated Content Manager tab (WordPress-like layout) with:
  - Stats dashboard (published, drafts, categories, quizzes, attempts)
  - Sidebar with category tree and filter controls (status, category)
  - Table-style content list with search
  - Full-page editor with title bar, meta fields, body editor, quiz builder
- Content Manager visible only for admin and moderator roles in sidebar
- Removed CMS from admin panel (admin panel is admin-only again)
- Email templates: fully responsive with max-width, proper mobile breakpoints,
  table-based layout for Outlook/Gmail compatibility, no overflow
- Security: escape all user data in emails with escHtml(), escape APP_URL
  in verify-email HTML, replace raw err.message with generic errors in auth
- Security: email body from admin settings now HTML-escaped before rendering
2026-03-23 20:13:22 -04:00
Daniel Onyejesi
4064ccf516 Fix quiz SQL bug, security hardening, componentize HTML, speed improvements
- Fix: "missing FROM-clause entry for table q" in quiz submission query
- Security: replace broken sanitizeHtml with allowlist-based sanitizer (prevents stored XSS)
- Security: whitelist table names in uniqueSlug() to prevent SQL injection pattern
- Security: replace raw err.message responses with generic errors in learning routes
- Security: cap admin logs query LIMIT to 500
- Refactor: break 1685-line index.html into 12 lazy-loaded component files (280 lines shell)
- Speed: add preconnect hints for Google Fonts and CDN
- Speed: defer all script tags for faster initial paint
- Speed: lazy-load tab HTML on activation (only active tab's DOM is parsed)
- Update: replace HIPAA notice with institution-guidelines cautionary note
2026-03-23 20:01:54 -04:00
Daniel Onyejesi
4af622913f v3.1: Add Learning Hub with CMS, quizzes, and 3-role user system
- New Learning Hub under Pediatric menu with content feed, category browsing,
  search, content viewer, and interactive quizzes (MCQ, true/false)
- Quiz system with per-option wrong-answer explanations and general explanations
- Admin CMS for creating/editing categories, content (articles, pearls, quizzes),
  and inline question builder with answer options
- 3-role system: admin (full access), moderator (can manage Learning Hub content),
  user (all clinical features + learning hub read access)
- 5 new database tables: learning_categories, learning_content, learning_questions,
  learning_options, learning_progress
- User progress tracking with score history per quiz
- Moderators see "Content Manager" tab instead of full Admin panel
2026-03-23 19:32:47 -04:00
Daniel
fc3c58e4eb Expand ROS/PE: pertinent negatives for WNL, clinical descriptions for abnormal
- WNL/Normal systems now expand to 1-3 specific pertinent negatives instead of
  just "WNL" — varies each time, relates to chief complaint and differential
  (e.g., Skin: "no rash, no petechiae, no bruising" instead of "WNL")
- Abnormal systems: AI expands physician's brief note into clinical description
  favoring most common presentation, adds only 1-2 related pertinent negatives
  (e.g., "pimples on face" → "inflammatory papules on bilateral cheeks...")
- Format function now passes system domain details for context
- Rules applied to well visit (full + short) and sick visit prompts
2026-03-23 23:45:09 +01:00
Daniel
3eb14af051 Add growth velocity, feeding guidance, BMI classification to well visits
- Growth reference data by age (newborn through 21y): expected weight/length/HC
  gains per day/month/year, puberty growth spurt details
- Feeding/nutrition guidance by age: formula amounts, breastfeeding frequency,
  solid food introduction timeline, juice limits, milk transitions, calorie needs
- BMI/obesity classification per AAP 2023 CPG + CDC Extended BMI:
  Underweight, Healthy, Overweight, Obesity Class I/II/III with action items
  (≥120% and ≥140% of 95th percentile thresholds)
- Weight-for-length guidance for children <2 years
- "By Visit" panel now shows growth reference + feeding guidance + BMI table
- Well visit AI prompt updated to include growth assessment, BMI classification,
  and feeding counseling in generated notes
- Backend injects age-appropriate growth/nutrition data into AI context
2026-03-23 23:11:24 +01:00
Daniel
d2ed08cd46 v3.0: Add multi-provider Bedrock models, newborn milestones, Converse API
- Bedrock: Add 14 non-vendor model models (Amazon Nova, Meta Llama 4, DeepSeek R1/V3,
  Mistral Large 3, Cohere Command R+, AI21 Jamba) with verified AWS model IDs
- Bedrock: Implement Converse API for non-Anthropic models (unified cross-model API),
  keep native Messages API for vendor model models (best performance)
- Milestones: Add Newborn / 1 month developmental milestones (Gross Motor, Fine Motor,
  Language, Social/Emotional, Cognitive) — previously started at 2 months
- Bump version to 3.0.0, docker-compose tag to v3.0
2026-03-23 22:13:19 +01:00
Daniel
eb8c86f70e Fix: Bedrock vendor model models updated with verified IDs from AWS docs
- Source: docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html
- Added: vendor model Opus 4.6 (anthropic.agent-config-opus-4-6-v1)
- Added: vendor model Opus 4.5 (anthropic.agent-config-opus-4-5-20251101-v1:0)
- Added: vendor model Sonnet 4.6 (anthropic.agent-config-sonnet-4-6) — new default
- Added: vendor model Sonnet 4.5 (anthropic.agent-config-sonnet-4-5-20250929-v1:0)
- Kept: vendor model Sonnet 4, Haiku 4.5, vendor model 3.5 Haiku
- Fallback updated to vendor model Haiku 4.5
- Bump version to 2.9.0
2026-03-23 18:23:26 +01:00
Daniel
fa6cdb8f24 Fix: chart review New clears all fields/notes, date context for AI, Bedrock vendor model-only models
- resetChartReview() clears all pasted notes, visit cards, labs, demographics, and output on New
- clearTab('chart') in encounters.js now calls resetChartReview() for complete reset
- chartReview route injects today's date so AI understands time-relative terms
- Bedrock models updated to vendor model-only with verified 2025 IDs (Sonnet 4, 3.7 Sonnet, 3.5 Sonnet v2, 3.5 Haiku)
- Removed Llama/Mistral/Titan from Bedrock; fixed incorrect vendor-model-4.6 Bedrock IDs
- Bump version to 2.8.0
2026-03-23 18:12:07 +01:00
Daniel Onyejesi
6375be96a3 Fix: auth-screen flex-direction column so footer sits below login box 2026-03-22 19:48:23 -04:00
Daniel Onyejesi
df6c3abaa4 Fix: footer on login page only, static position at bottom of auth screen 2026-03-22 19:46:25 -04:00
Daniel Onyejesi
3cea15734b Fix: replace footer with slim fixed bar shown on all pages incl. login 2026-03-22 19:43:09 -04:00
Daniel Onyejesi
0851d47ab9 Feat: show footer on login page with transparent styling 2026-03-22 19:40:35 -04:00
Daniel Onyejesi
4d45050522 Feat: add footer with healthcare equity statement and copyright 2026-03-22 19:37:36 -04:00
Daniel Onyejesi
a6b6d0dffb Revert: undo model selector color-scheme overrides (local Adwaita theme issue, not a code bug) 2026-03-22 19:23:47 -04:00
Daniel Onyejesi
2cd0eaa511 Fix: force color-scheme on model selects to prevent OS dark mode bleed-through 2026-03-22 19:18:51 -04:00
Daniel Onyejesi
38d07ff37f Feat: v2.7 — sidebar UX, well visit subtabs, larger boxes, sick visit reorder
- Remove duplicate milestones sidebar button; well visit button now works correctly
- Move Sick Visit sidebar entry to just below Well Visit (before Vaccine/Catch-Up)
- Sick Visit remains its own standalone tab (not a subtab)
- Well Visit subtabs: By Visit Age | Milestones | SSHADESS (12+) | Visit Note
- Dramatically larger transcript boxes (min-height 240px, max-height 600px)
- Dramatically larger note output boxes (min-height 480px) — beautiful scrollable
- Collapsible desktop sidebar with localStorage persistence
- Update README with full feature list
- Bump docker-compose to v2.7
2026-03-22 19:14:41 -04:00
Daniel Onyejesi
78730a669f Bump image to v2.6 2026-03-22 19:00:29 -04:00
Daniel Onyejesi
aecf72453f Fix: milestones copy-to-note auto-navigates; add 6-11y milestone data
- Copy to Note button now switches to Well Visit → Visit Note subtab
  automatically so user sees the result immediately
- Add complete AAP/Nelson developmental milestones for ages 6, 7, 8, 9,
  10, and 11 years (Gross Motor, Fine Motor, Language, Social/Emotional,
  Cognitive domains each)
- Add corresponding age options to ms-age-group select
2026-03-22 19:00:02 -04:00
Daniel Onyejesi
2e4d0f1f6e Bump image to v2.5 2026-03-22 18:52:35 -04:00
Daniel Onyejesi
90c7bc35af Feat: milestones under well visit, standalone schedule tabs, remove admin models
- Move Milestones from standalone tab to well visit subtab (wv-panel-milestones)
  with 'Copy to Note' button that fills Developmental Assessment card in Visit Note
- Sidebar Milestones button now navigates to Well Visit → Milestones subtab
- Move Full Vaccine Schedule → standalone sidebar tab (vaxschedule-tab)
- Move Catch-Up Schedule → standalone sidebar tab (catchup-tab)
- Add Developmental Assessment textarea (wv-milestones-text) to Visit Note panel
- Remove AI Models management from admin panel (was failing to load)
- switchSubtab scoped to wellvisit-tab so standalone schedule panels unaffected
- Expose window.wvSwitchSubtab for cross-module subtab switching
2026-03-22 18:52:11 -04:00
Daniel Onyejesi
bfa832713d Bump docker-compose image to v2.4 2026-03-22 18:34:07 -04:00
Daniel Onyejesi
2da4401d80 Fix: admin models/2FA loading, save doubles, sidebar collapse, SSHADESS copy
- Fix 2FA status stuck at "Loading...": export load2FAStatus globally so
  app.js tabChanged handler can call it when navigating via sidebar
- Fix admin models silent failure: add error message + retry button on catch
- Fix save encounter doubles: persist _savedEncId_* to sessionStorage so
  page refresh reuses existing record instead of inserting a duplicate;
  add _savingInProgress guard to prevent race-condition double-saves
- Desktop sidebar collapsible: add collapse/expand buttons (not collapsed
  by default), state saved to localStorage; hidden on mobile
- Copy to Visit Note now also copies SSHADESS assessment to wv-shadess-text
- Store selected visit age in window._wellVisitAge + sessionStorage so
  other tabs can read the patient's age
- Bigger screening/vaccine boxes: min-height on wv-vax-list, wv-screen-list,
  wv-visit-detail; slightly more padding on vaccine items
- Wire New Patient (clearTab) buttons to document click handler
2026-03-22 18:33:28 -04:00
Daniel Onyejesi
3449b0efca Fix: save/load encounters — hidden popovers, label IDs, note IDs
- CSS: .enc-load-popover.hidden now overrides display:flex (specificity bug
  caused popovers to always be visible)
- resumeEncounter: add domPfxMap to correctly resolve label element IDs
  (enc_type 'wellvisit' → 'wv-label', 'hospital' → 'hosp-label', etc.)
- resumeEncounter: use noteIdMap to find the right output element per tab
- resumeEncounter: use enc.enc_type (not tabName) as _savedEncId_ key for
  consistency with saveFromTab
2026-03-22 17:39:04 -04:00
Daniel Onyejesi
9544d43b16 Chore: bump production docker image to v2.3 2026-03-22 17:18:06 -04:00
Daniel Onyejesi
56b8961a55 Feat: left sidebar nav, tab persistence, admin models fix, hospital/chart save
- Replace horizontal tab-nav with collapsible left sidebar (sticky desktop, slide-in mobile with hamburger toggle)
- Fix tab persistence: use localStorage instead of URL hash; restore last tab after login in enterApp()
- Fix admin CMS models loading: move autoDeleteDays read inside .then() callback (was ReferenceError), switch admin loading from click-only to tabChanged event
- Add Save/Load bars to Hospital Course and Chart Review tabs with popover support
- Add wv (well visit) save handler in encounters.js
- Register load handlers for hospital and chart tabs
- Expand popover prefix list to include hosp and chart
2026-03-22 17:17:45 -04:00
Daniel Onyejesi
075353886e Chore: bump production image tag to v2.2 2026-03-22 16:57:08 -04:00
Daniel Onyejesi
8317708bb6 Fix: load popover positioning, delete button, ICD-10 live search
- Wrap save-bar + popover in .save-bar-wrap (position:relative) so the
  absolute-positioned popover anchors correctly below the Load button
  (previously popovers were outside the save-bar, so had no positioned
  ancestor and appeared at the wrong place on screen)
- Remove confirm() dialog from deleteEncounter — encounter disappears
  immediately from the popover list after deletion
- ICD-10 diagnoses: replace static-only lookup with live NLM Clinical
  Tables API search (clinicaltables.nlm.nih.gov) with 280ms debounce;
  results appear in a dropdown below the search input; Enter/click selects
- Add clinicaltables.nlm.nih.gov to CSP connect-src
- Drop unused position:relative from .save-bar (now on .save-bar-wrap)
2026-03-22 16:56:28 -04:00
Daniel Onyejesi
d4759fcf58 Chore: bump production image tag to v2.1 2026-03-22 16:44:27 -04:00
Daniel Onyejesi
5dd08f8da3 Feat: admin CMS, save/resume encounters, user templates, SSHADESS, well visit notes, sick visit, ROS/PE checklists, ICD-10 diagnoses
- Admin CMS: announcements, feature flags, email templates, AI prompts, SMTP, model management, reset-to-defaults
- Save/resume encounter progress with 7-day auto-expiry and patient labels
- Inline searchable load popover (replaces settings-modal redirect)
- User memory/template system (physical exam, ROS, encounter format, etc.)
- SSHADESS psychosocial screening form (8 domains, listen-in recording)
- Well visit note generation with vitals, vaccines, screenings, SSHADESS
- Interactive screenings/vaccines status buttons (Done/Refused/Already Given/N/A)
- ROS 15-system checklist + PE 17-system checklist in well visit note tab
- ICD-10 diagnoses tag picker (28 common pediatric codes + free text)
- Simple sick visit tab: chief complaint, inferred ROS (4) + PE (7), diagnoses, generate note
- Settings converted from modal to full-page tab
- Fix: /api/models moved before authenticated routes (was returning 401 → empty model selector)
- Fix: getUserMemoryContext always fetches from API regardless of cache state
- Pause/resume recording on all recording tabs
- Site-level SMTP config stored in DB; ElevenLabs TTS with browser fallback
2026-03-22 16:43:39 -04:00
Daniel Onyejesi
cd73bec5f8 Feat: add stethoscope+pencil favicon in SVG, ICO, and PNG sizes 2026-03-22 14:09:18 -04:00
Daniel Onyejesi
93338567e8 Feat: restore ElevenLabs Adam voice for read aloud with browser TTS fallback; add client-side error logging 2026-03-22 12:27:17 -04:00
Daniel Onyejesi
e74c0ccb0c Fix: clear password fields after autofill delay and add autocomplete=off on login form 2026-03-22 12:18:29 -04:00
Daniel Onyejesi
39a1affcbc Fix: clear password fields, remove has-session class, and reset hash on logout 2026-03-22 12:13:41 -04:00
Daniel Onyejesi
a2481a0ea7 Fix: service worker only intercepts same-origin requests, preventing CDN fetch errors 2026-03-22 00:41:24 -04:00
Daniel Onyejesi
12189a0101 Fix: eliminate auth screen flash by hiding it synchronously in <head> before render 2026-03-22 00:36:20 -04:00
Daniel Onyejesi
393cb3f1e0 Fix: hide auth screen immediately on load if token exists, preventing login flash 2026-03-22 00:31:49 -04:00
Daniel Onyejesi
0c2d3398e0 Feat: persist active tab in URL hash across page refreshes 2026-03-22 00:26:13 -04:00
Daniel Onyejesi
9b1766837d Bump image tag to v1.2 in docker-compose.yml 2026-03-22 00:16:51 -04:00
Daniel Onyejesi
9c476b520a Fix: init Well Visit tab on DOMContentLoaded instead of lazy tabChanged event 2026-03-22 00:10:46 -04:00
Daniel Onyejesi
383fa2b149 Fix: remove build context from docker-compose so production always pulls from Docker Hub 2026-03-22 00:02:52 -04:00
Daniel Onyejesi
f64e84fa1e Fix: use versioned Docker tag v1.1 instead of latest
Prevents Docker from skipping pulls when tag name hasn't changed.
2026-03-21 23:59:10 -04:00
Daniel Onyejesi
362eaadeb0 Fix: set no-cache headers for HTML, short cache for JS/CSS
Prevents Caddy/browser from caching index.html across deployments.
2026-03-21 23:54:48 -04:00
Daniel Onyejesi
f154adf032 Fix: add missing Dengue and Mpox labels to VACCINE_FULL_NAMES 2026-03-21 23:44:12 -04:00
Daniel Onyejesi
a51dd8e788 Feat: add Well Visit / Preventive Care tab with AAP 2025 data
- Add Well Visit tab with three sub-views: By Visit Age, Full Vaccine Schedule, Catch-Up Schedule
- By Visit Age: shows ICD-10/CPT billing codes, vaccines due with dose info, measurements, sensory/developmental/behavioral/procedure screenings, oral health, notes
- Full Schedule: scrollable table of all vaccines vs all visit ages
- Catch-Up Schedule: per-vaccine minimum ages, intervals, and catch-up notes per CDC 2025
- Add pediatricScheduleData.js (1719-line AAP 2025 Bright Futures dataset)
- Add wellVisit.js for tab logic (lazy-initialized on first tab activation)
- Fire tabChanged CustomEvent on tab switch for lazy initialization
- Add Well Visit CSS styles to styles.css
2026-03-21 23:19:24 -04:00
Daniel Onyejesi
95cae5e5f2 Fix: milestone list — achieved states naturally, not-achieved uses Cannot/Does not
Achieved: 'Stands on one foot' (not 'Can stand on one foot')
Not achieved: 'Cannot stand on one foot' / 'Does not yet use 2-word sentences'
2026-03-21 22:48:26 -04:00
Daniel Onyejesi
a9fbf7a003 Fix: milestone list uses natural language sentences instead of X/checkmark
Replace checkmark/X symbol format with plain numbered sentences:
achieved → 'Can walk independently'
not achieved → 'Cannot walk' / 'Does not yet use 2-word sentences'
2026-03-21 22:45:55 -04:00
Daniel Onyejesi
e3d4ab5949 Improve email templates and update README
- Replace bare-bones email HTML with branded templates: gradient header,
  action button, plain-text URL fallback, security footer notice
- Welcome/verify email: friendly greeting with user's name
- Password reset email: clear instructions, 1-hour expiry notice,
  explicit 'if you didn't request this, ignore it' message
- README: updated features list, vendor model 4.6 models, plain-text output note
2026-03-21 22:43:19 -04:00
Daniel Onyejesi
3e4ebe579e Fix: add scriptSrcAttr unsafe-inline to allow onclick handlers
Helmet 8 adds script-src-attr: 'none' by default which blocks ALL
inline event attribute handlers (onclick, onchange, etc.) regardless
of script-src unsafe-inline. This silently broke every onclick button:
copy, read aloud, nextcloud upload, generate, record, etc.
2026-03-21 22:12:17 -04:00
Daniel Onyejesi
9f2dee0875 Fix: update all Font Awesome 5 icon names to Font Awesome 6
FA6 renamed many icons — using old FA5 names caused blank buttons/icons:
- fa-cloud-upload-alt    → fa-cloud-arrow-up   (Nextcloud upload button)
- fa-sign-out-alt        → fa-right-from-bracket (logout button)
- fa-magic               → fa-wand-magic-sparkles (all Generate buttons)
- fa-volume-up           → fa-volume-high      (all Read buttons + stopReading)
- fa-compress-alt        → fa-compress         (Shorter + 3-Sentence Summary)
- fa-file-medical-alt    → fa-file-waveform    (SOAP tab header)
- fa-file-alt            → fa-file-lines       (Transcript/Input cards)
- fa-shield-alt          → fa-shield-halved    (HIPAA notice + 2FA section)
- fa-sync-alt            → fa-rotate           (Admin refresh button)
- fa-ambulance           → fa-truck-medical    (ED note section)
- fa-question-circle     → fa-circle-question  (What's Missing button)
2026-03-21 21:58:14 -04:00
Daniel Onyejesi
624e815226 Fix: expand CSP connectSrc for CDN/TTS; add vendor model 4.6 models
- CSP connectSrc was blocking service worker re-fetches to cdnjs.cloudflare.com
  causing Font Awesome icons to go blank (settings, logout, nextcloud buttons)
- Add cdnjs, googleapis, gstatic, google to connectSrc to fix icon rendering
  and Chrome Web Speech API (which connects to Google servers for TTS voices)
- Add vendor model Opus 4.6 and vendor model Sonnet 4.6 to all three provider lists
  (OpenRouter, Bedrock, Azure-compatible); Sonnet 4.6 now default for Bedrock
2026-03-21 21:43:00 -04:00
Daniel Onyejesi
12083a5ed1 Fix: revert speakText to sync speechSynthesis; robust copyText fallback
- speakText: revert async ElevenLabs fetch chain — browser requires
  speechSynthesis.speak() to be called synchronously within user gesture;
  async .catch() context caused silent failure across browsers
- copyText: fix fallback that always showed 'Copied!' even when it failed;
  now shows real error message, uses selectNodeContents for accuracy,
  handles missing clipboard API gracefully
2026-03-21 21:31:48 -04:00
Daniel Onyejesi
3ab109b84a Fix: enforce plain text output in all AI prompts (no markdown)
Add PLAIN TEXT ONLY rule to CORE_RULES so no prompt returns asterisks,
pound signs, or other markdown symbols — output is copy-ready as-is.
2026-03-21 21:06:13 -04:00
Daniel Onyejesi
1f84a58813 Upgrade TTS to ElevenLabs Adam voice with frontend integration
- Switch speakText() to call /api/text-to-speech backend (ElevenLabs) with fallback to native speechSynthesis
- Upgrade ElevenLabs voice from Rachel to Adam (pNInz6obpgDQGcFmaJgB) — warmer, more professional
- Upgrade model from eleven_monolingual_v1 to eleven_turbo_v2_5 for better quality and lower latency
2026-03-21 20:55:50 -04:00
ifedan-ed
275b16ca9b Security: CSP, CORS origin lock, 1mb body limit, auth brute-force rate limits
- Enable Helmet CSP (scripts/styles/fonts/connect restricted to self + CDNs)
- Lock CORS to APP_URL origin in production (open in dev)
- Reduce JSON body limit from 50mb to 1mb
- Add per-route rate limits: login 10/15min, register 5/hr, forgot-pw 5/hr
- Add README with full setup, Docker Hub, provider switching, env reference
- Bump version to 3.0.0
2026-03-21 19:32:40 -04:00
ifedan-ed
a4b111c4d4 Add .dockerignore (exclude .env, .agent-config, node_modules, data) 2026-03-21 19:27:21 -04:00
ifedan-ed
ac8e7bb890 v3.0.0: Auth, admin panel, security fixes, per-tab model selector
- Add authMiddleware to all AI/transcribe routes (were unauthenticated)
- Add full admin panel: user management, registration toggle, stats
- Fix XSS in email verification (escape user.name in HTML)
- Fix missing APP_URL fallback in password reset email
- Add per-tab model selector (respects OpenRouter/Bedrock/Azure lists)
- Fix transcribeAudio to send Authorization header
- Fix labs input: textarea instead of single-line input
- Add structured logging: audit_log, api_log, access_log tables
- Add admin CLI (admin-cli.js) for Docker exec management
- Fix duplicate var duration declaration in ai.js catch block
- Fix RETURNING check case-sensitivity in database.js
2026-03-21 19:25:51 -04:00
47 changed files with 1377 additions and 223 deletions

View file

@ -179,5 +179,5 @@ EMBEDDING_DIMENSIONS=768
# ============================================================
# DATABASE
# ============================================================
DATABASE_URL=postgresql://pedscribe:pedscribe_secret_change_me@postgres:5432/pedscribe
DATABASE_URL=postgresql://pedscribe:<password>@postgres:5432/pedscribe
DB_PASSWORD=pedscribe_secret_change_me

View file

@ -24,7 +24,7 @@ services:
environment:
POSTGRES_DB: pedscribe
POSTGRES_USER: pedscribe
POSTGRES_PASSWORD: ${DB_PASSWORD:-pedscribe_secret_change_me}
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped

View file

@ -15,7 +15,7 @@ services:
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: 200
CLINICAL_ASSISTANT_PROMPT_POOL_TARGET: 1000
volumes:
- scribe-logs:/app/data/logs
- clinical-assistant-mcp-data:/app/mcp-data:ro
@ -28,9 +28,9 @@ services:
container_name: pediatric-ai-scribe
networks:
- default
- mcp-server_default
- monitoring_default
- speech_net
- danvics_mcp
- danvics_monitoring
- danvics_speech
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s
@ -46,7 +46,7 @@ services:
environment:
POSTGRES_DB: pedscribe
POSTGRES_USER: pedscribe
POSTGRES_PASSWORD: ${DB_PASSWORD:-zy9LK958SAcfUM7SJTkdthSYyYy6}
POSTGRES_PASSWORD: ${DB_PASSWORD:-pedscribe}
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
@ -72,7 +72,7 @@ services:
retries: 5
networks:
- default
- mcp-server_default
- danvics_mcp
volumes:
pgdata:
@ -83,9 +83,9 @@ volumes:
name: mcp-server_mcp-data
networks:
mcp-server_default:
danvics_mcp:
external: true
monitoring_default:
danvics_monitoring:
external: true
speech_net:
danvics_speech:
external: true

View file

@ -103,8 +103,7 @@ Authenticate a user. Supports optional TOTP two-factor authentication. On succes
{
"email": "string",
"password": "string",
"totpCode": "string (optional, required if 2FA is enabled)",
"turnstileToken": "string"
"totpCode": "string (optional, required if 2FA is enabled)"
}
```
- **Response:**

View file

@ -123,9 +123,23 @@ necessary UX tradeoff over perfect indistinguishability.
## 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).
`/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
`src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from

View file

@ -2,14 +2,15 @@
// SESSION PERSISTENCE — full logout → login → still on the same
// tab + same sub-pill.
//
// The UI's login form is gated by a Cloudflare Turnstile token
// whose site key is hardcoded in index.html, which can't be
// completed in the e2e container (Turnstile rejects the non-prod
// origin). So the test does a programmatic logout (clear the
// ped_auth cookie, same effect server-side as clicking Logout)
// followed by a fresh programmatic login — this exercises the
// same localStorage persistence path a real logout/login would,
// without depending on the bot challenge.
// The test does a programmatic logout (clear the ped_auth cookie,
// same effect server-side as clicking Logout) followed by a fresh
// programmatic login. This exercises the same localStorage
// persistence path a real logout/login would.
//
// (Historically this was a workaround for the Turnstile challenge on
// the login form, which could not be completed in the e2e container.
// 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');

View file

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

View file

@ -15,6 +15,8 @@ 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.WebChromeClient;
import android.webkit.WebViewClient;
@ -36,6 +38,11 @@ public class MainActivity extends BridgeActivity {
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
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@ -47,6 +54,9 @@ public class MainActivity extends BridgeActivity {
new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE);
}
// Allow the Cloudflare Turnstile iframe to use storage.
setupThirdPartyCookies();
// Setup WebView mic permission granting
setupWebViewPermissions();
@ -60,6 +70,79 @@ public class MainActivity extends BridgeActivity {
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
private void setupWebViewPermissions() {
@ -124,6 +207,7 @@ public class MainActivity extends BridgeActivity {
public void startForegroundService() {
Intent intent = new Intent(activity, AudioRecordingService.class);
ContextCompat.startForegroundService(activity, intent);
activity.setRecordingActive(true);
}
@android.webkit.JavascriptInterface
@ -131,6 +215,17 @@ public class MainActivity extends BridgeActivity {
Intent intent = new Intent(activity, AudioRecordingService.class);
intent.setAction(AudioRecordingService.ACTION_STOP);
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);
}
}

View file

@ -13,7 +13,7 @@
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</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:navigationBarColor">@color/colorPrimaryDark</item>
</style>

View file

@ -1,12 +1,12 @@
{
"name": "pedscribe-mobile",
"version": "7.14.7",
"version": "7.14.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pedscribe-mobile",
"version": "7.14.7",
"version": "7.14.14",
"dependencies": {
"@aparajita/capacitor-biometric-auth": "^8.0.0",
"@capacitor/android": "^6.0.0",

View file

@ -1,6 +1,6 @@
{
"name": "pedscribe-mobile",
"version": "7.14.7",
"version": "7.14.16",
"description": "PedScribe native mobile app — Capacitor wrapper for Pediatric AI Scribe",
"private": true,
"scripts": {

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "pediatric-ai-scribe",
"version": "7.14.7",
"version": "7.14.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pediatric-ai-scribe",
"version": "7.14.7",
"version": "7.14.14",
"dependencies": {
"@marp-team/marp-cli": "^4.3.1",
"@marp-team/marp-core": "^4.3.0",

View file

@ -1,6 +1,6 @@
{
"name": "pediatric-ai-scribe",
"version": "7.14.7",
"version": "7.14.16",
"description": "AI-powered pediatric clinical documentation platform",
"main": "server.js",
"scripts": {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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>
</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;">
<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>

View file

@ -11,6 +11,9 @@
<button class="wv-subtab-btn" data-subtab="milestones">
<i class="fas fa-baby"></i> Milestones
</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;">
<i class="fas fa-brain"></i> SSHADESS (12+)
</button>
@ -119,6 +122,65 @@
</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+) -->
<div id="wv-panel-shadess" class="wv-subpanel hidden">
<div class="card" style="margin-bottom:10px;">
@ -305,4 +367,3 @@
</div>
</div>
</div>

View file

@ -397,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 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 */
.wv-billing-grid{display:flex;flex-wrap:wrap;gap:12px;align-items:center;}
.wv-billing-cell{display:flex;align-items:center;gap:8px;}
@ -627,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-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-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 input[type="radio"]{accent-color:var(--blue);width:18px;height:18px;flex-shrink:0;}
.lh-quiz-option span{flex:1;}

View file

@ -11,7 +11,11 @@
<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"
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"
integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
@ -70,7 +74,6 @@
<label>2FA Code</label>
<input type="text" id="login-totp" placeholder="6-digit code" maxlength="6">
</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>
<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>
@ -104,7 +107,7 @@
<label>Password (8+ characters)</label>
<input type="password" id="reg-password" required minlength="8" placeholder="••••••••">
</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>
<div class="auth-links">
<a href="#" id="show-login">Back to sign in</a>
@ -118,7 +121,7 @@
<label>Email</label>
<input type="email" id="forgot-email" required placeholder="your@email.com">
</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>
<div class="auth-links">
<a href="#" id="show-login-2">Back to sign in</a>
@ -131,7 +134,7 @@
</div>
<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)
</a>
</div>

View file

@ -701,6 +701,8 @@ function adminFlashButtonBackground(btn, color) {
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin();
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-test-assistant-image-model')) testAssistantImageModel();
if (e.target.closest('#btn-use-custom-assistant-image-model')) useCustomAssistantImageModel();
@ -741,6 +743,7 @@ function adminFlashButtonBackground(btn, color) {
}
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
loadAssistantImageModels();
loadAssistantPromptPoolStatus();
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
setValue('assistant-system-behavior', cfg['clinical_assistant.system_behavior'] || defaults.behavior);
@ -810,6 +813,91 @@ function adminFlashButtonBackground(btn, color) {
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() {
var model = getValue('assistant-image-model') || 'openai-gpt-image-1';
var result = document.getElementById('assistant-image-test-result');

View file

@ -600,9 +600,19 @@ window.nativeStopRecordingService = function() {
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) {
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 (on) window.Capacitor.Plugins.KeepAwake.keepAwake();
else window.Capacitor.Plugins.KeepAwake.allowSleep();

View file

@ -5,9 +5,9 @@ export function createAssistantImageStore() {
var generatedImageSeq = 0;
var previewKeyHandler = null;
function renderGeneratedImage(src, alt) {
function renderGeneratedImage(src, alt, downloadUrl) {
var id = 'img-' + (++generatedImageSeq);
generatedImages[id] = src;
generatedImages[id] = { src: src, downloadUrl: downloadUrl || '' };
return '<div class="assistant-generated-image"><img src="' + escapeAttr(src) + '" alt="' + escapeAttr(alt || 'Generated image') + '">' +
'<div class="assistant-image-actions">' +
'<button type="button" class="btn-sm btn-ghost" data-assistant-open-image="' + escapeAttr(id) + '"><i class="fas fa-expand"></i> Preview</button>' +
@ -16,7 +16,8 @@ export function createAssistantImageStore() {
}
function openImagePreview(id) {
var src = generatedImages[id];
var item = generatedImages[id];
var src = item && (item.src || item);
if (!src) return;
closeImagePreview();
var modal = document.createElement('div');
@ -41,14 +42,24 @@ export function createAssistantImageStore() {
}
async function downloadImage(id) {
var src = generatedImages[id];
var item = generatedImages[id];
var src = item && (item.src || item);
var downloadUrl = item && item.downloadUrl;
if (!src) return;
try {
if (downloadUrl) {
await downloadFromServer(downloadUrl);
return;
}
if (await saveWithNativeShare(src)) return;
if (isNativeApp()) {
if (typeof window.showToast === 'function') window.showToast('Image saving is not available in this app build yet. Update the app and try again.', 'error');
return;
}
if (isMobileBrowser()) {
if (typeof window.showToast === 'function') window.showToast('Mobile browser download is not supported here. Use Preview and long-press the image to save it.', 'info');
return;
}
await downloadWithBrowser(src);
} catch (e) {
if (typeof window.showToast === 'function') window.showToast(e.message || 'Image download failed', 'error');
@ -123,7 +134,7 @@ async function saveWithCapacitorShare(src) {
if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success');
return true;
} catch (e) {
if (typeof window.showToast === 'function') window.showToast('Could not save with the app. Opening browser download instead.', 'error');
if (typeof window.showToast === 'function') window.showToast('Could not save with the app. Use Preview and long-press the image to save it.', 'error');
return false;
}
}
@ -136,10 +147,14 @@ async function shareWithWebFile(src) {
if (!navigator.canShare({ files: [file] })) return 'unavailable';
await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' });
return 'shared';
} catch (e) { return isShareCancel(e) ? 'cancelled' : 'unavailable'; }
} catch (e) { return isShareCancel(e) ? 'unavailable' : 'unavailable'; }
}
async function downloadWithBrowser(src) {
if (isMobileBrowser()) {
if (typeof window.showToast === 'function') window.showToast('Mobile browser download is not supported here. Use Preview and long-press the image to save it.', 'info');
return;
}
var blob = await imageSourceToBlob(src);
var objectUrl = URL.createObjectURL(blob);
var a = document.createElement('a');
@ -150,15 +165,79 @@ async function downloadWithBrowser(src) {
a.click();
a.remove();
setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000);
}
async function downloadFromServer(url) {
var response = await fetch(url, { headers: authHeadersForDownload(), credentials: 'same-origin' });
if (!response.ok) throw new Error('Image download failed');
var blob = await response.blob();
var name = 'clinical-visual-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.png';
if (await saveBlobWithNativeImageBridge(blob, name)) return;
if (await saveBlobWithCapacitorShare(blob, name)) return;
if (await shareBlobWithWebFile(blob, name)) return;
if (isMobileBrowser()) {
setTimeout(function() {
var opened = window.open(objectUrl, '_blank', 'noopener');
if (opened && typeof window.showToast === 'function') {
window.showToast('If the download did not start, long-press the opened image and save it.', 'success');
}
}, 300);
if (typeof window.showToast === 'function') window.showToast('Could not start download here. Use Preview and long-press the image to save it.', 'error');
return;
}
downloadBlobWithAnchor(blob, name);
}
function authHeadersForDownload() {
var headers = window.getAuthHeaders ? window.getAuthHeaders() : {};
var clean = {};
Object.keys(headers || {}).forEach(function(key) {
if (key.toLowerCase() !== 'content-type') clean[key] = headers[key];
});
return clean;
}
async function saveBlobWithNativeImageBridge(blob, name) {
if (!window.NativeFiles || typeof window.NativeFiles.saveImage !== 'function') return false;
try {
var base64 = await blobToBase64(blob);
var result = String(window.NativeFiles.saveImage(name, base64) || '');
if (result.indexOf('saved:') === 0) {
if (typeof window.showToast === 'function') window.showToast('Image saved to Photos', 'success');
return true;
}
} catch (e) {}
return false;
}
async function saveBlobWithCapacitorShare(blob, name) {
var plugins = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins : null;
if (!plugins || !plugins.Filesystem) return false;
try {
var base64 = await blobToBase64(blob);
var saved = await plugins.Filesystem.writeFile({ path: name, data: base64, directory: 'CACHE' });
if (plugins.Share && saved && saved.uri) {
await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: saved.uri, dialogTitle: 'Save or share clinical visual' });
}
if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success');
return true;
} catch (e) { return false; }
}
async function shareBlobWithWebFile(blob, name) {
if (!navigator.share || !navigator.canShare || typeof File === 'undefined') return false;
try {
var file = new File([blob], name, { type: blob.type || 'image/png' });
if (!navigator.canShare({ files: [file] })) return false;
await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' });
return true;
} catch (e) { return false; }
}
function downloadBlobWithAnchor(blob, name) {
var objectUrl = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = objectUrl;
a.download = name;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000);
}
function isMobileBrowser() {
@ -177,6 +256,10 @@ function isShareCancel(error) {
async function imageSourceToBase64(src) {
if (/^data:image\//i.test(src)) return src.split(',')[1] || '';
var blob = await imageSourceToBlob(src);
return blobToBase64(blob);
}
async function blobToBase64(blob) {
return new Promise(function(resolve, reject) {
var reader = new FileReader();
reader.onload = function() { resolve(String(reader.result || '').split(',')[1] || ''); };

View file

@ -47,50 +47,86 @@ document.addEventListener('DOMContentLoaded', function() {
// 2FA still applies on top: biometric replaces the password step but
// a 2FA-enabled account still gets the TOTP prompt afterwards. That
// is the intended defense-in-depth.
var BIO_SERVER = 'pedscribe-bio'; // namespace for the keychain/keystore item
// Two plugins cooperate here, because the one that does the biometric
// prompt does not store anything:
// - BiometricAuthNative (@aparajita/capacitor-biometric-auth) — presents
// the Face ID / fingerprint prompt. checkBiometry() + authenticate().
// - SecureStoragePlugin (capacitor-secure-storage-plugin), via the
// window.SecureStorage wrapper — holds the credentials in the iOS
// Keychain / Android EncryptedSharedPreferences.
//
// This previously called window.Capacitor.Plugins.NativeBiometric, the API
// of capacitor-native-biometric — a package that is not a dependency of this
// project. The plugin object was always undefined, so bioAvailable() always
// resolved {ok:false} and the biometric button was never revealed. The
// feature has been dead since it was written.
var BIO_CREDS_KEY = 'ped_bio_creds'; // SecureStorage key holding {username,password}
var BIO_ENABLED_KEY = 'ped_bio_enabled'; // localStorage flag — used to decide whether to even probe
// BiometryType enum from the plugin (numeric) → human label.
var BIO_TYPE_NAMES = {
1: 'Touch ID',
2: 'Face ID',
3: 'fingerprint',
4: 'face recognition',
5: 'iris recognition'
};
function bioPlugin() {
try {
if (!isNativeApp()) return null;
var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.NativeBiometric;
var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.BiometricAuthNative;
return p || null;
} catch (e) { return null; }
}
function bioAvailable() {
var p = bioPlugin();
if (!p) return Promise.resolve({ ok: false });
return p.isAvailable()
.then(function (r) { return { ok: !!(r && r.isAvailable), type: r && r.biometryType }; })
return p.checkBiometry()
.then(function (r) {
return {
ok: !!(r && r.isAvailable),
type: r && r.biometryType,
typeName: (r && BIO_TYPE_NAMES[r.biometryType]) || 'biometric'
};
})
.catch(function () { return { ok: false }; });
}
function bioStored() {
// Cheap check first — was biometric ever enrolled? If not, skip the
// verifyIdentity prompt path entirely so we don't rattle the user.
// prompt path entirely so we don't rattle the user.
try { return localStorage.getItem(BIO_ENABLED_KEY) === '1'; } catch (e) { return false; }
}
function bioEnroll(email, password) {
var p = bioPlugin();
if (!p) return Promise.reject(new Error('Biometric plugin unavailable'));
return p.setCredentials({ username: email, password: password, server: BIO_SERVER })
.then(function () { try { localStorage.setItem(BIO_ENABLED_KEY, '1'); } catch (e) {} });
if (!bioPlugin()) return Promise.reject(new Error('Biometric plugin unavailable'));
if (!window.SecureStorage) return Promise.reject(new Error('Secure storage unavailable'));
return Promise.resolve(
window.SecureStorage.set(BIO_CREDS_KEY, JSON.stringify({ username: email, password: password }))
).then(function () { try { localStorage.setItem(BIO_ENABLED_KEY, '1'); } catch (e) {} });
}
function bioRetrieve() {
var p = bioPlugin();
if (!p) return Promise.reject(new Error('Biometric plugin unavailable'));
return p.verifyIdentity({
if (!window.SecureStorage) return Promise.reject(new Error('Secure storage unavailable'));
// authenticate() resolves on success and rejects on cancel/failure, so the
// credentials are only read after the OS has verified the user.
return p.authenticate({
reason: 'Sign in to PedScribe',
title: 'PedScribe',
subtitle: 'Use biometric to sign in',
description: 'Confirm your identity to continue.'
androidTitle: 'PedScribe',
androidSubtitle: 'Use biometric to sign in',
cancelTitle: 'Use password',
allowDeviceCredential: false
}).then(function () {
return p.getCredentials({ server: BIO_SERVER });
return window.SecureStorage.get(BIO_CREDS_KEY);
}).then(function (raw) {
if (!raw) throw new Error('No stored credentials');
return JSON.parse(raw);
});
}
function bioForget() {
var p = bioPlugin();
try { localStorage.removeItem(BIO_ENABLED_KEY); } catch (e) {}
if (!p) return Promise.resolve();
return p.deleteCredentials({ server: BIO_SERVER }).catch(function () { /* fine if missing */ });
if (!window.SecureStorage) return Promise.resolve();
return Promise.resolve(window.SecureStorage.remove(BIO_CREDS_KEY)).catch(function () { /* fine if missing */ });
}
// Expose a small surface so settings/logout/etc can call into it.
window.PedBio = { available: bioAvailable, stored: bioStored, enroll: bioEnroll, retrieve: bioRetrieve, forget: bioForget };
@ -113,9 +149,8 @@ document.addEventListener('DOMContentLoaded', function() {
if (!s.ok) return;
// Tweak the label to the actual biometry type when known.
var label = document.getElementById('bio-login-label');
if (label && s.type) {
var typeMap = { 'FACE_ID': 'Sign in with Face ID', 'TOUCH_ID': 'Sign in with Touch ID', 'FACE_AUTHENTICATION': 'Sign in with face recognition', 'FINGERPRINT': 'Sign in with fingerprint' };
label.textContent = typeMap[s.type] || 'Sign in with biometric';
if (label && s.typeName) {
label.textContent = 'Sign in with ' + s.typeName;
}
btn.classList.remove('hidden'); btn.style.display = '';
if (div) { div.classList.remove('hidden'); div.style.display = ''; }
@ -214,6 +249,71 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
// ---- CLOUDFLARE TURNSTILE ----
// Gates registration and password reset. Login is deliberately NOT gated:
// it is already covered by a 10-per-15-min rate limit and a constant-time
// credential check, and the widget is unreliable inside the Capacitor
// WebView — which locked mobile users out of the app entirely.
//
// Tokens are captured from the render callback rather than read back out of
// the injected [name="cf-turnstile-response"] input. That lookup is easy to
// leave unscoped, which is exactly how the register form ended up
// submitting the login widget's token (single-use, 5-minute expiry).
//
// Rendering is explicit and deferred until the owning form is visible:
// both widgets live in forms that start at display:none, and Turnstile does
// not reliably complete a challenge inside a hidden container.
var turnstileWidgets = {
register: { el: 'turnstile-register', id: null, token: '', pending: false },
forgot: { el: 'turnstile-forgot', id: null, token: '', pending: false }
};
var turnstileReady = false;
// api.js?render=explicit invokes this once the Turnstile API is available.
window.onloadTurnstileCallback = function() {
turnstileReady = true;
Object.keys(turnstileWidgets).forEach(function(name) {
// Catch up on any form shown before the script finished loading.
if (turnstileWidgets[name].pending) renderTurnstile(name);
});
};
function renderTurnstile(name) {
var w = turnstileWidgets[name];
if (!w || w.id !== null) return; // already rendered
var el = document.getElementById(w.el);
if (!el) return;
if (!turnstileReady || !window.turnstile) { w.pending = true; return; }
w.pending = false;
w.id = window.turnstile.render(el, {
sitekey: el.getAttribute('data-sitekey'),
theme: 'light',
callback: function(token) { w.token = token; },
'expired-callback': function() { w.token = ''; },
'timeout-callback': function() { w.token = ''; },
// Without this a widget failure is silent and the user only ever sees
// the generic "complete the verification" toast with no way to tell
// whether the challenge failed, expired, or never loaded at all.
'error-callback': function(code) {
w.token = '';
console.error('[Auth] Turnstile error on ' + name + ' widget:', code);
showToast('Verification unavailable (' + (code || 'error') + '). Check your connection and try again.', 'error');
}
});
}
function turnstileToken(name) {
var w = turnstileWidgets[name];
return w ? w.token : '';
}
function resetTurnstile(name) {
var w = turnstileWidgets[name];
if (!w) return;
w.token = '';
if (w.id !== null && window.turnstile) window.turnstile.reset(w.id);
}
// ---- HELPER FUNCTIONS ----
function showLoginForm() {
@ -226,12 +326,14 @@ document.addEventListener('DOMContentLoaded', function() {
if (loginForm) loginForm.style.display = 'none';
if (registerForm) registerForm.style.display = 'block';
if (forgotForm) forgotForm.style.display = 'none';
renderTurnstile('register');
}
function showForgotForm() {
if (loginForm) loginForm.style.display = 'none';
if (registerForm) registerForm.style.display = 'none';
if (forgotForm) forgotForm.style.display = 'block';
renderTurnstile('forgot');
}
function enterApp(user, token) {
@ -527,7 +629,7 @@ document.addEventListener('DOMContentLoaded', function() {
// ---- BIOMETRIC LOGIN BUTTON ----
// Reads the email + password from the OS-secured keychain (gated behind
// Face ID / Touch ID / fingerprint) and fills the login form. Submits the
// form so all the existing flow (turnstile, 2FA prompt, error handling,
// form so all the existing flow (2FA prompt, error handling,
// session storage) runs unchanged. If biometric verification fails, the
// user just gets a toast and falls through to typing the password.
var bioBtn = document.getElementById('btn-bio-login');
@ -544,10 +646,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (emailEl) emailEl.value = creds.username;
if (pwEl) pwEl.value = creds.password;
// Trigger the same submit path as the password form so all the
// existing handling (turnstile token, 2FA, session storage, etc.)
// runs unchanged. If turnstile hasn't auto-solved yet the form
// will toast "Please complete the verification" — same as a
// manual login attempt before turnstile resolves.
// existing handling (2FA, session storage, etc.) runs unchanged.
if (loginForm && typeof loginForm.requestSubmit === 'function') loginForm.requestSubmit();
else if (loginForm) loginForm.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
})
@ -577,17 +676,9 @@ document.addEventListener('DOMContentLoaded', function() {
return false;
}
// Cloudflare Turnstile
var loginTurnstile = document.querySelector('#login-form [name="cf-turnstile-response"]');
var loginToken = loginTurnstile ? loginTurnstile.value : '';
if (!loginToken) {
showToast('Please complete the verification', 'error');
return false;
}
showLoading('Signing in...');
var body = { email: email, password: password, turnstileToken: loginToken };
var body = { email: email, password: password };
if (totpCode) body.totpCode = totpCode;
fetch('/api/auth/login', {
@ -625,7 +716,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (isNativeApp() && !bioStored()) {
bioAvailable().then(function (s) {
if (!s.ok) return;
var typeName = ({ 'FACE_ID': 'Face ID', 'TOUCH_ID': 'Touch ID', 'FACE_AUTHENTICATION': 'face recognition', 'FINGERPRINT': 'fingerprint' })[s.type] || 'biometric';
var typeName = s.typeName || 'biometric';
if (typeof showConfirm === 'function') {
showConfirm('Enable ' + typeName + ' for faster sign-in next time?', function () {
bioEnroll(email, password)
@ -637,14 +728,12 @@ document.addEventListener('DOMContentLoaded', function() {
}
} else {
showToast(data.error || 'Login failed', 'error');
if (window.turnstile) turnstile.reset('#turnstile-login');
}
})
.catch(function(err) {
hideLoading();
console.error('[Auth] Login error:', err);
showToast('Connection error', 'error');
if (window.turnstile) turnstile.reset('#turnstile-login');
});
return false;
@ -703,9 +792,8 @@ document.addEventListener('DOMContentLoaded', function() {
}
// Cloudflare Turnstile verification
var turnstileResponse = document.querySelector('[name="cf-turnstile-response"]');
var turnstileToken = turnstileResponse ? turnstileResponse.value : '';
if (!turnstileToken) {
var regToken = turnstileToken('register');
if (!regToken) {
showToast('Please complete the verification challenge', 'error');
return false;
}
@ -715,7 +803,7 @@ document.addEventListener('DOMContentLoaded', function() {
fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: turnstileToken })
body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: regToken })
})
.then(function(r) { return r.json(); })
.then(function(data) {
@ -729,17 +817,20 @@ document.addEventListener('DOMContentLoaded', function() {
showToast(data.message || 'Account created!', 'success');
} else if (data.success && data.needsVerification) {
showToast(data.message || 'Check email to verify', 'success');
// The token was just consumed server-side — clear it so coming back
// to this form doesn't resubmit a spent one.
resetTurnstile('register');
showLoginForm();
} else {
showToast(data.error || 'Registration failed', 'error');
if (window.turnstile) turnstile.reset();
resetTurnstile('register');
}
})
.catch(function(err) {
hideLoading();
console.error('[Auth] Register error:', err);
showToast('Connection error', 'error');
if (window.turnstile) turnstile.reset();
resetTurnstile('register');
});
return false;
@ -756,8 +847,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (!email) { showToast('Enter email', 'error'); return false; }
// Cloudflare Turnstile
var forgotTurnstile = document.querySelector('#forgot-form [name="cf-turnstile-response"]');
var forgotToken = forgotTurnstile ? forgotTurnstile.value : '';
var forgotToken = turnstileToken('forgot');
if (!forgotToken) {
showToast('Please complete the verification', 'error');
return false;
@ -774,12 +864,13 @@ document.addEventListener('DOMContentLoaded', function() {
.then(function(data) {
hideLoading();
showToast(data.message || 'Check your email', 'success');
resetTurnstile('forgot');
showLoginForm();
})
.catch(function(err) {
hideLoading();
showToast('Error', 'error');
if (window.turnstile) turnstile.reset('#turnstile-forgot');
resetTurnstile('forgot');
});
return false;

View file

@ -492,10 +492,11 @@ import {
if (!src) throw new Error('No image returned');
lastGeneratedImageSrc = src;
exporter.invalidate();
if (out) out.innerHTML = imageStore.renderGeneratedImage(src, 'Generated clinical visual');
var downloadUrl = data.downloadUrl || '';
if (out) out.innerHTML = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl);
if (fromChat) {
setBusy(false, 'Ready');
var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual');
var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl);
replaceLoadingMessage(loading, html, [], [], true);
}
})

View file

@ -5,7 +5,6 @@
# Usage:
# scripts/release.sh 6.1.1 # bump to 6.1.1
# scripts/release.sh 6.1.1 --push # also git push + tag push
# scripts/release.sh 6.2.0 --push --gh # also create GitHub release
#
# What it does:
# 1. Updates version in root package.json
@ -13,31 +12,32 @@
# 3. Updates versionName + bumps versionCode in Android build.gradle
# 4. Commits the version bump
# 5. (optional) git push + push the new tag
# 6. (optional) create a GitHub release via gh CLI
#
# It does NOT:
# - Build the Docker image (run `docker compose build`/`up -d` yourself
# or wire it to a deploy script / CI hook)
# - Build the Android APK (run `cd mobile/android && ./gradlew ...`
# yourself). This script just marks the version so the build tags
# correctly.
# - Build the Android APK itself. Forgejo CI does that
# (.forgejo/workflows/android-apk.yml): any branch push builds a signed
# APK as a 30-day workflow artifact, and a v* tag push additionally
# attaches it to a Forgejo release, which is what Obtainium tracks for
# updates. So --push is all you need to ship a build.
# - Publish the release itself. CI does that on the tag push; there is no
# manual publish step to run.
# ============================================================
set -euo pipefail
VERSION="${1:-}"
PUSH=false
DO_RELEASE=false
for arg in "${@:2}"; do
case "$arg" in
--push) PUSH=true ;;
--gh) DO_RELEASE=true ;;
esac
done
if [[ -z "$VERSION" ]]; then
echo "usage: $0 <version> [--push] [--gh]"
echo " example: $0 6.1.1 --push --gh"
echo "usage: $0 <version> [--push]"
echo " example: $0 6.1.1 --push"
exit 1
fi
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
@ -90,30 +90,25 @@ git tag -a "v${VERSION}" -m "Release v${VERSION}"
echo " tagged v${VERSION}"
if $PUSH; then
git push origin HEAD
git push origin "v${VERSION}"
echo " pushed to origin"
fi
if $DO_RELEASE; then
if ! command -v gh >/dev/null; then
echo "WARN: gh CLI not installed, skipping GitHub release creation" >&2
else
APK="mobile/android/app/build/outputs/apk/release/app-release.apk"
if [[ -f "$APK" ]]; then
gh release create "v${VERSION}" "$APK" \
--title "PedScribe ${VERSION}" \
--notes "Release ${VERSION}" \
--latest
echo " created GitHub release with APK"
else
gh release create "v${VERSION}" \
--title "PedScribe ${VERSION}" \
--notes "Release ${VERSION}" \
--latest
echo " created GitHub release (no APK attached — run gradle first then gh release upload)"
fi
# This repo's remote is "forgejo", not "origin". Prefer forgejo, fall back
# to origin, otherwise use the only remote there is — so this keeps working
# if the remote is ever renamed. Pushing the tag is what makes CI attach the
# signed APK to a Forgejo release for Obtainium; the branch push alone only
# builds it as a 30-day workflow artifact.
REMOTE=""
for candidate in forgejo origin; do
if git remote get-url "$candidate" >/dev/null 2>&1; then REMOTE="$candidate"; break; fi
done
if [[ -z "$REMOTE" ]]; then
REMOTE=$(git remote | head -1)
fi
if [[ -z "$REMOTE" ]]; then
echo "ERROR: no git remote configured — cannot push. Commit and tag are still local." >&2
exit 1
fi
git push "$REMOTE" HEAD
git push "$REMOTE" "v${VERSION}"
echo " pushed to $REMOTE"
fi
echo "==> Done. v$VERSION."

View file

@ -376,6 +376,26 @@ async function initDatabase() {
console.warn('⚠️ Could not create milestones table:', e.message);
}
// Durable history for generated clinical assistant starter prompts.
// Redis serves the active pool; Postgres keeps snapshots for rollback.
try { await client.query(`
CREATE TABLE IF NOT EXISTS clinical_prompt_pool_snapshots (
id SERIAL PRIMARY KEY,
generated_at TIMESTAMPTZ DEFAULT NOW(),
target INTEGER DEFAULT 0,
count INTEGER DEFAULT 0,
payload JSONB NOT NULL,
restored_from INTEGER REFERENCES clinical_prompt_pool_snapshots(id) ON DELETE SET NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_prompt_pool_snapshots_created ON clinical_prompt_pool_snapshots(created_at DESC);
`);
console.log('✅ clinical_prompt_pool_snapshots: table ready');
} catch(e) {
console.warn('⚠️ Could not create clinical prompt pool snapshots table:', e.message);
}
// Create IVFFLAT index for fast similarity search (after data is populated)
try {
var indexExists = await client.query(

View file

@ -9,7 +9,7 @@ var { authMiddleware, adminMiddleware } = require('../middleware/auth');
var PROMPTS = require('../utils/prompts');
var logger = require('../utils/logger');
var { gatewayUrl } = require('../utils/errors');
var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider');
var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider');
var { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../utils/litellm');
var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
var { getLiteLLMEmbeddingModels } = require('../utils/embeddings');
@ -91,6 +91,55 @@ router.get('/config', async function(req, res) {
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// ── Clinical assistant starter prompt pool ─────────────────────────────────
router.get('/clinical-assistant/prompt-pool', async function(req, res) {
try {
var clinicalAssistant = require('./clinicalAssistant');
var meta = await clinicalAssistant.getPromptPoolMeta();
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20);
res.json({ success: true, meta: meta || null, snapshots: snapshots });
} catch (e) {
res.status(500).json({ error: e.message || 'Prompt pool status failed' });
}
});
router.get('/clinical-assistant/prompt-pool/snapshots', async function(req, res) {
try {
var clinicalAssistant = require('./clinicalAssistant');
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(req.query.limit || 20);
res.json({ success: true, snapshots: snapshots });
} catch (e) {
res.status(500).json({ error: e.message || 'Prompt pool snapshot listing failed' });
}
});
router.post('/clinical-assistant/prompt-pool/regenerate', async function(req, res) {
try {
var clinicalAssistant = require('./clinicalAssistant');
var examples = await clinicalAssistant.refreshPromptPool(true, req.user && req.user.id);
var meta = await clinicalAssistant.getPromptPoolMeta();
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20);
res.json({ success: true, count: examples.length, meta: meta || null, snapshots: snapshots });
} catch (e) {
res.status(500).json({ error: e.message || 'Prompt pool regeneration failed' });
}
});
router.post('/clinical-assistant/prompt-pool/restore', async function(req, res) {
try {
var clinicalAssistant = require('./clinicalAssistant');
var id = Number(req.body && req.body.id);
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Snapshot id is required' });
var payload = await clinicalAssistant.restorePromptPoolSnapshot(id, req.user && req.user.id);
if (!payload) return res.status(404).json({ error: 'Snapshot not found' });
var meta = await clinicalAssistant.getPromptPoolMeta();
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20);
res.json({ success: true, count: Array.isArray(payload.examples) ? payload.examples.length : 0, meta: meta || null, snapshots: snapshots });
} catch (e) {
res.status(500).json({ error: e.message || 'Prompt pool restore failed' });
}
});
// ── POST send test email ───────────────────────────────────────────────────
router.post('/config/test-email', async function(req, res) {
try {
@ -501,12 +550,17 @@ router.get('/config/tts', async function(req, res) {
var dbModel = await db.getSetting('tts.model') || '';
var envVoice = process.env.LITELLM_TTS_VOICE || '';
var envModel = process.env.LITELLM_TTS_MODEL || '';
var currentModel = dbModel || envModel;
var voices = getLiteLLMTTSVoicesForModel(currentModel, { currentVoice: dbVoice });
var currentVoice = [dbVoice, envVoice, voices[0]].find(function(voice) {
return isLiteLLMTTSVoiceCompatible(currentModel, voice);
}) || '';
res.json({
success: true,
provider: activeProvider,
envProvider: envProvider,
currentVoice: dbVoice || envVoice,
currentModel: dbModel || envModel,
currentVoice: currentVoice,
currentModel: currentModel,
dbVoice: dbVoice,
dbModel: dbModel,
envVoice: envVoice,
@ -514,7 +568,9 @@ router.get('/config/tts', async function(req, res) {
configured: {
litellm: !!process.env.LITELLM_API_BASE
},
voices: getTTSVoiceLists()
voices: {
litellm: voices
}
});
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
@ -567,11 +623,15 @@ router.post('/config/tts/test', async function(req, res) {
var adminModel = await db.getSetting('tts.model') || '';
var adminVoice = await db.getSetting('tts.voice') || '';
var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || '';
var usedVoice = voice || adminVoice || process.env.LITELLM_TTS_VOICE || '';
var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice });
var usedVoice = [voice, adminVoice, process.env.LITELLM_TTS_VOICE || '', defaultVoices[0]].find(function(candidate) {
return isLiteLLMTTSVoiceCompatible(ttsModel, candidate);
}) || '';
if (!ttsModel) return res.json({ success: false, error: 'No LiteLLM TTS model configured' });
var payload = Object.assign({ model: ttsModel, voice: usedVoice, input: text }, getLiteLLMTTSRequestOptions(ttsModel));
var ttsResp = await axios.post(gatewayUrl('/audio/speech'),
{ model: ttsModel, voice: usedVoice, input: text },
payload,
{ headers: getLiteLLMHeaders('application/json'), responseType: 'arraybuffer', timeout: 60000 }
);
var buffer = Buffer.from(ttsResp.data);

View file

@ -299,19 +299,15 @@ router.post('/resend-verification', async (req, res) => {
// ============================================================
router.post('/login', async (req, res) => {
try {
var { email, password, totpCode, turnstileToken } = req.body;
var { email, password, totpCode } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
// Cloudflare Turnstile verification
if (process.env.TURNSTILE_SECRET_KEY) {
if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification' });
var tsRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip })
});
var tsData = await tsRes.json();
if (!tsData.success) return res.status(400).json({ error: 'Verification failed. Please try again.' });
}
// No Turnstile on login. The widget could not reliably complete a
// challenge inside the Capacitor WebView, which locked mobile users out.
// Brute-force cover here comes from the 10-per-15-min per-IP rate limit
// (server.js), the constant-time bcrypt comparison below, and TOTP 2FA.
// Registration and password reset — the endpoints that actually attract
// bots — are still gated.
var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
// Enumeration-resistant: always run bcrypt to keep timing constant, and return

View file

@ -55,7 +55,9 @@ var promptPool = createClinicalPromptPool({
semanticSearch: semanticSearch,
dedupeSources: dedupeSources,
normalizeMcpSearchResponse: normalizeMcpSearchResponse,
getIndexedTopicExamples: getIndexedTopicExamples
getIndexedTopicExamples: getIndexedTopicExamples,
loadStoredPromptPool: loadLatestPromptPoolSnapshot,
savePromptPool: savePromptPoolSnapshot
});
function positiveInt(value, fallback) {
@ -323,6 +325,7 @@ router.get('/clinical-assistant/image/jobs/:id', async function(req, res) {
imageUrl: job.imageUrl || null,
url: job.url || null,
base64: job.base64 || null,
downloadUrl: job.status === 'done' && job.base64 ? '/api/clinical-assistant/image/jobs/' + encodeURIComponent(job.id) + '/download' : null,
error: job.error || null
});
} catch (e) {
@ -330,6 +333,22 @@ router.get('/clinical-assistant/image/jobs/:id', async function(req, res) {
}
});
router.get('/clinical-assistant/image/jobs/:id/download', async function(req, res) {
try {
var job = await getImageJob(req.params.id);
if (!job || String(job.userId) !== String(req.user.id) || job.status !== 'done' || !job.base64) {
return res.status(404).json({ error: 'Image job not found' });
}
var buffer = Buffer.from(String(job.base64), 'base64');
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Disposition', 'attachment; filename="clinical-visual.png"');
res.setHeader('Cache-Control', 'private, max-age=900');
res.send(buffer);
} catch (e) {
res.status(500).json({ error: 'Image download failed' });
}
});
async function prepareAssistantChat(body) {
body = body || {};
var message = String(body.message || '').trim();
@ -568,6 +587,49 @@ async function getAvailableExamples() {
return promptPool.getAvailableExamples();
}
router.refreshPromptPool = function(force, userId) {
return promptPool.refreshIfNeeded(force !== false, { userId: userId || null });
};
router.getPromptPoolMeta = function() {
return promptPool.getMeta();
};
router.getPromptPoolSnapshots = getPromptPoolSnapshots;
router.restorePromptPoolSnapshot = restorePromptPoolSnapshot;
async function savePromptPoolSnapshot(payload, context) {
context = context || {};
var result = await db.run(
'INSERT INTO clinical_prompt_pool_snapshots (generated_at, target, count, payload, restored_from, created_by) VALUES (to_timestamp($1 / 1000.0), $2, $3, $4::jsonb, $5, $6) RETURNING id',
[payload.generatedAt || Date.now(), payload.target || 0, Array.isArray(payload.examples) ? payload.examples.length : 0, JSON.stringify(payload), context.restoredFrom || null, context.userId || null]
);
return result.lastInsertRowid;
}
async function loadLatestPromptPoolSnapshot() {
var row = await db.get('SELECT payload FROM clinical_prompt_pool_snapshots ORDER BY created_at DESC, id DESC LIMIT 1', []);
if (!row || !row.payload) return null;
return typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
}
async function getPromptPoolSnapshots(limit) {
limit = clampInt(limit || 20, 1, 50, 20);
return db.all(
'SELECT id, generated_at, target, count, restored_from, created_at FROM clinical_prompt_pool_snapshots ORDER BY created_at DESC, id DESC LIMIT $1',
[limit]
);
}
async function restorePromptPoolSnapshot(id, userId) {
var row = await db.get('SELECT id, payload FROM clinical_prompt_pool_snapshots WHERE id = $1', [id]);
if (!row || !row.payload) return null;
var original = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
var payload = Object.assign({}, original, { generatedAt: Date.now(), restoredFrom: Number(row.id) });
await promptPool.writePromptPool(payload, { userId: userId || null, restoredFrom: Number(row.id) });
return payload;
}
async function getIndexedTopicExamples() {
var response = await indexedTopicSuggestions(12);
var data = response && (response.structuredContent || response.data || response);
@ -590,7 +652,18 @@ async function getIndexedTopicExamples() {
sourceTitle: Array.isArray(item.sample_titles) ? item.sample_titles[0] : '',
category: item.category || ''
};
}).filter(function(item) { return item.prompt; });
}).filter(isUsefulIndexedTopicExample);
}
function isUsefulIndexedTopicExample(item) {
var label = String(item && item.label || '');
var prompt = String(item && item.prompt || '');
if (!prompt || prompt.indexOf('?') === -1) return false;
var haystack = (label + ' ' + prompt + ' ' + String(item.sourceTitle || '')).toLowerCase();
if (/\b(start|end) of picture text\b/.test(haystack)) return false;
if (/\b(comparative|cross[-\s]?sectional|retrospective|prospective) study\b/.test(label.toLowerCase())) return false;
if (/\b(prevalence|correlation|association) of\b/.test(label.toLowerCase())) return false;
return true;
}
async function generateImage(prompt, model) {

View file

@ -3,7 +3,7 @@ const router = express.Router();
const { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
var { gatewayUrl } = require('../utils/errors');
var { getTTSProvider } = require('../utils/ttsProvider');
var { getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider');
var { getLiteLLMHeaders } = require('../utils/litellm');
// TTS is intentionally routed only through LiteLLM. Provider-specific voice
@ -22,19 +22,22 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => {
var userVoice = userPrefs?.tts_voice;
var adminVoice = await db.getSetting('tts.voice') || '';
var adminModel = await db.getSetting('tts.model') || '';
var resolvedVoice = userVoice || adminVoice || '';
if (ttsProvider !== 'litellm' || !process.env.LITELLM_API_BASE) {
return res.status(400).json({ error: 'TTS not configured. Set LITELLM_API_BASE.' });
}
var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || '';
var ttsVoice = resolvedVoice || process.env.LITELLM_TTS_VOICE || '';
var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice });
var ttsVoice = [userVoice, adminVoice, process.env.LITELLM_TTS_VOICE || '', defaultVoices[0]].find(function(voice) {
return isLiteLLMTTSVoiceCompatible(ttsModel, voice);
}) || '';
if (!ttsModel) return res.status(400).json({ error: 'No LiteLLM TTS model configured.' });
var payload = Object.assign({ model: ttsModel, input: text, voice: ttsVoice }, getLiteLLMTTSRequestOptions(ttsModel));
var ttsResp = await fetch(gatewayUrl('/audio/speech'), {
method: 'POST',
headers: getLiteLLMHeaders('application/json'),
body: JSON.stringify({ model: ttsModel, input: text, voice: ttsVoice })
body: JSON.stringify(payload)
});
if (!ttsResp.ok) {
var errBody = await ttsResp.text();

View file

@ -7,7 +7,7 @@ var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var { getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
var { getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider');
var { getLiteLLMTTSVoicesForModel, getTTSProvider } = require('../utils/ttsProvider');
router.use(authMiddleware);
@ -49,14 +49,18 @@ router.get('/preferences/options', async function(req, res) {
var provider = getSTTProvider();
var ttsProvider = getTTSProvider();
var dbModel = await db.getSetting('tts.model') || '';
var dbVoice = await db.getSetting('tts.voice') || '';
var ttsModel = dbModel || process.env.LITELLM_TTS_MODEL || '';
var sttModels = getSTTModelLists().litellm.map(function(model) { return { value: model, label: model }; });
var ttsVoices = getTTSVoiceLists().litellm.map(function(voice) { return { value: voice, label: voice }; });
var ttsVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: dbVoice }).map(function(voice) { return { value: voice, label: voice }; });
res.json({
success: true,
sttProvider: provider || 'none',
sttModels: sttModels,
ttsProvider: ttsProvider || 'browser',
ttsModel: ttsModel || null,
ttsVoices: ttsVoices
});

View file

@ -8,6 +8,227 @@ function clip(text, max) {
return text.length > max ? text.substring(0, max - 1).trimEnd() + '…' : text;
}
var PEDIATRIC_TAXONOMY = [
{
category: 'neonates', weight: 85, ageBands: ['neonate'],
seeds: [
'febrile neonate sepsis evaluation empiric antibiotics lumbar puncture',
'neonatal jaundice bilirubin phototherapy cholestasis red flags',
'newborn respiratory distress differential management NICU admission',
'neonate poor feeding vomiting bilious emesis congenital obstruction',
'neonatal hypoglycemia seizures lethargy initial management'
]
},
{
category: 'respiratory', weight: 85, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric asthma exacerbation severity treatment discharge criteria',
'bronchiolitis infant oxygen hydration admission criteria',
'croup stridor epiglottitis bacterial tracheitis airway red flags',
'pediatric pneumonia wheezing foreign body aspiration differential',
'cystic fibrosis respiratory exacerbation airway clearance antibiotics'
]
},
{
category: 'gi', weight: 75, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric abdominal pain appendicitis intussusception differential',
'acute gastroenteritis dehydration oral rehydration ondansetron',
'bloody diarrhea hemolytic uremic syndrome STEC salmonella',
'pediatric vomiting bilious emesis increased intracranial pressure',
'constipation encopresis functional abdominal pain counseling'
]
},
{
category: 'infectious_disease', weight: 75, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric fever without source serious bacterial infection workup',
'meningitis encephalitis sepsis child empiric antibiotics',
'pertussis exposure prophylaxis vaccination household contacts',
'skin soft tissue infection cellulitis abscess child antibiotics',
'fever of unknown origin child infectious noninfectious differential'
]
},
{
category: 'emergency', weight: 70, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric emergency triage red flags resuscitation shock',
'child altered mental status seizure hypoglycemia toxic ingestion',
'pediatric anaphylaxis epinephrine airway hypotension disposition',
'trauma child head injury abdominal injury non accidental trauma',
'pediatric shock dehydration sepsis cardiac emergency management'
]
},
{
category: 'derm', weight: 55, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric rash fever petechiae purpura emergency differential',
'newborn skin lesions hemangioma melanocytic nevus epidermolysis bullosa',
'eczema atopic dermatitis impetigo cellulitis child management',
'urticaria angioedema anaphylaxis pediatric counseling',
'Kawasaki disease mucocutaneous findings MIS-C differential'
]
},
{
category: 'endocrine', weight: 55, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'diabetic ketoacidosis child fluids insulin cerebral edema',
'pediatric hypoglycemia adrenal insufficiency endocrine emergency',
'short stature delayed puberty precocious puberty evaluation',
'thyroid disease child hyperthyroidism hypothyroidism symptoms',
'polyuria polydipsia diabetes insipidus diabetes mellitus child'
]
},
{
category: 'cardiology', weight: 55, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric chest pain syncope murmur red flags cardiology referral',
'cyanotic congenital heart disease neonate prostaglandin ductal dependent',
'heart failure infant poor feeding tachypnea hepatomegaly',
'arrhythmia palpitations supraventricular tachycardia child management',
'Kawasaki disease coronary aneurysm aspirin IVIG'
]
},
{
category: 'neurology', weight: 65, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric headache red flags neuroimaging referral',
'first seizure child febrile seizure epilepsy workup',
'altered mental status child meningitis encephalitis toxic metabolic',
'developmental regression weakness ataxia neurologic emergency',
'migraine child acute treatment prevention counseling'
]
},
{
category: 'renal', weight: 50, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'urinary tract infection child fever pyelonephritis imaging',
'hematuria proteinuria nephrotic nephritic syndrome child',
'acute kidney injury child dehydration sepsis nephrotoxin workup',
'hypertension pediatric evaluation renal endocrine cardiac',
'electrolyte disorders child sodium potassium emergency management'
]
},
{
category: 'rheum', weight: 45, ageBands: ['toddler', 'school_age', 'adolescent'],
seeds: [
'juvenile idiopathic arthritis limp joint swelling fever differential',
'Kawasaki MIS-C fever rash conjunctivitis pediatric differential',
'pediatric lupus vasculitis purpura renal symptoms workup',
'periodic fever syndromes child aphthous pharyngitis adenitis',
'limp child septic arthritis osteomyelitis transient synovitis'
]
},
{
category: 'hematology', weight: 55, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric anemia microcytic hemolytic iron deficiency workup',
'sickle cell fever pain crisis acute chest child management',
'thrombocytopenia petechiae purpura ITP leukemia child differential',
'bleeding bruising child coagulation disorder non accidental trauma',
'neutropenia fever oncology pediatric emergency antibiotics'
]
},
{
category: 'development', weight: 60, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'developmental milestones delay screening autism speech motor',
'ADHD learning difficulty school performance pediatric assessment',
'autism spectrum toddler screening counseling referral',
'developmental regression child neurologic metabolic genetic evaluation',
'well child developmental behavioral screening anticipatory guidance'
]
},
{
category: 'toxicology', weight: 45, ageBands: ['toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric ingestion acetaminophen iron button battery emergency',
'adolescent overdose suicide risk toxidrome initial management',
'carbon monoxide poisoning child headache altered mental status',
'caustic ingestion child drooling dysphagia endoscopy',
'medication poisoning toddler decontamination antidote observation'
]
},
{
category: 'procedures', weight: 45, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric lumbar puncture indications contraindications consent',
'laceration repair child sedation analgesia wound infection',
'foreign body removal ear nose airway child management',
'splinting fracture child neurovascular assessment analgesia',
'procedural sedation pediatric fasting monitoring discharge criteria'
]
},
{
category: 'dosing_fluids', weight: 70, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric maintenance fluids dehydration bolus electrolyte correction',
'weight based medication dosing child safety maximum dose',
'oral rehydration solution dosing gastroenteritis child',
'DKA fluids insulin potassium pediatric cerebral edema',
'antibiotic dosing renal adjustment pediatric infection'
]
},
{
category: 'adolescent', weight: 60, ageBands: ['adolescent'],
seeds: [
'adolescent abdominal pain pregnancy STI pelvic inflammatory disease',
'depression suicide screening adolescent confidentiality safety plan',
'eating disorder adolescent bradycardia weight loss admission criteria',
'substance use adolescent confidential history counseling',
'sports injury concussion return to play adolescent'
]
},
{
category: 'counseling', weight: 55, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'parent counseling fever return precautions child safety net',
'vaccine counseling pediatric hesitancy contraindications',
'asthma action plan inhaler technique parent education',
'gastroenteritis home care hydration return precautions',
'newborn discharge anticipatory guidance feeding jaundice safe sleep'
]
}
];
var VALID_INTENTS = {
diagnosis: true,
management: true,
red_flags: true,
differential: true,
counseling: true,
dosing: true,
admission: true,
discharge: true,
review: true
};
var VALID_AGE_BANDS = {
neonate: true,
infant: true,
toddler: true,
school_age: true,
adolescent: true
};
function taxonomyByCategory() {
var out = {};
PEDIATRIC_TAXONOMY.forEach(function(item) { out[item.category] = item; });
return out;
}
var TAXONOMY_BY_CATEGORY = taxonomyByCategory();
var CATEGORY_ALIASES = {
'infectious disease': 'infectious_disease',
infectious: 'infectious_disease',
'dosing/fluids': 'dosing_fluids',
dosing: 'dosing_fluids',
fluids: 'dosing_fluids',
'red flags': 'emergency',
'admission/discharge': 'emergency',
general: 'counseling'
};
function parseJsonObject(text) {
text = String(text || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
try { return JSON.parse(text); } catch (e) {}
@ -22,20 +243,23 @@ function parseJsonObject(text) {
function createClinicalPromptPool(opts) {
opts = opts || {};
var cacheMs = positiveInt(process.env.CLINICAL_ASSISTANT_EXAMPLE_CACHE_MS, 10 * 60 * 1000);
var refreshMs = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 24 * 60 * 60 * 1000);
var ttlSeconds = Math.max(3600, Math.ceil(refreshMs / 1000) * 2);
var target = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 200);
var redisKey = process.env.CLINICAL_ASSISTANT_PROMPT_POOL_KEY || 'clinical-assistant:prompt-pool:v1';
var refreshMs = nonNegativeInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 7 * 24 * 60 * 60 * 1000);
var ttlSeconds = refreshMs > 0 ? Math.max(3600, Math.ceil(refreshMs / 1000) * 2) : 0;
var target = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 1000);
var redisBaseKey = String(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_KEY || 'clinical-assistant:prompt-pool:v2').replace(/:all$/, '');
var redisAllKey = redisBaseKey + ':all';
var redisMetaKey = redisBaseKey + ':meta';
var redisLastGoodKey = redisBaseKey + ':last-good';
var memoryCache = { expiresAt: 0, examples: [] };
var refreshPromise = null;
async function getAvailableExamples() {
var now = Date.now();
if (memoryCache.expiresAt > now && memoryCache.examples.length) return memoryCache.examples;
var cached = await opts.redisCache.getJson(redisKey).catch(function() { return null; });
var cached = await readPromptPool();
if (cached && Array.isArray(cached.examples) && cached.examples.length) {
memoryCache = { expiresAt: now + cacheMs, examples: cached.examples };
if (!cached.generatedAt || now - cached.generatedAt > refreshMs) refreshIfNeeded(false).catch(function() {});
if (refreshMs > 0 && (!cached.generatedAt || now - cached.generatedAt > refreshMs)) refreshIfNeeded(false).catch(function() {});
return cached.examples;
}
@ -47,82 +271,105 @@ function createClinicalPromptPool(opts) {
return examples;
}
async function refreshIfNeeded(force) {
async function refreshIfNeeded(force, context) {
context = context || {};
var now = Date.now();
var cached = await opts.redisCache.getJson(redisKey).catch(function() { return null; });
if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && cached.generatedAt && now - cached.generatedAt < refreshMs) {
var cached = await readPromptPool();
if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && (refreshMs === 0 || (cached.generatedAt && now - cached.generatedAt < refreshMs))) {
return cached.examples;
}
if (refreshPromise) return refreshPromise;
refreshPromise = buildCorpusPromptPool().then(function(examples) {
refreshPromise = null;
if (!examples.length) return [];
var payload = { generatedAt: Date.now(), examples: examples };
var payload = { generatedAt: Date.now(), target: target, examples: examples };
memoryCache = { expiresAt: Date.now() + cacheMs, examples: examples };
return opts.redisCache.setJson(redisKey, payload, ttlSeconds).then(function() { return examples; });
return writePromptPool(payload, context).then(function() { return examples; });
}).catch(function(e) {
refreshPromise = null;
console.warn('[clinical-assistant] prompt pool refresh failed:', e.message);
return [];
return cached && Array.isArray(cached.examples) ? cached.examples : [];
});
return refreshPromise;
}
async function buildCorpusPromptPool() {
var snippets = await collectPromptSeedSnippets();
if (!snippets.length) return fallbackIndexedExamples();
var chatModel = await opts.getSetting('clinical_assistant.prompt_model', '') || process.env.CLINICAL_ASSISTANT_PROMPT_MODEL || await opts.getSetting('clinical_assistant.chat_model', '') || await opts.getSetting('models.default', '');
var generated = [];
var batches = Math.max(1, Math.min(40, Math.ceil(target / 25)));
for (var i = 0; i < batches && generated.length < target; i++) {
var batchSnippets = rotateExamples(snippets, i * 24).slice(0, 24);
var sourceText = batchSnippets.map(function(s, idx) {
return '[' + (idx + 1) + '] ' + s.title + (s.page ? ', page ' + s.page : '') + '\n' + clip(s.excerpt, 650);
}).join('\n\n');
var ai = await opts.callAI([
{
role: 'system',
content: 'Generate realistic pediatric clinical assistant starter questions from indexed source snippets. Use only the provided titles/snippets as inspiration. Do not answer the questions. Do not mention source names. Prefer broad practical clinical questions involving diagnosis, red flags, severity, treatment, dosing, fluids, admission/discharge, counseling, or differential diagnosis. Return strict JSON only: {"questions":[{"label":"2-5 word label","prompt":"question","category":"emergency|infectious disease|neonates|respiratory|gi|neurology|cardiology|endocrine|hematology|development|toxicology|procedures|dosing/fluids|red flags|differential|admission/discharge|counseling|general","intent":"diagnosis|management|red_flags|differential|counseling|dosing|admission|review"}]}.'
},
{ role: 'user', content: 'Indexed pediatric source snippets:\n\n' + sourceText + '\n\nCreate 35 varied starter questions. Make this batch different from common asthma, croup, and UTI examples when possible. Include a mix of emergencies, outpatient questions, red flags, diagnostics, treatment, counseling, fluids, dosing, admission/discharge, and differentials.' }
], {
model: chatModel || undefined,
temperature: 0.75,
maxTokens: 2600
});
var parsed = parseJsonObject(String(ai.content || ''));
generated = normalizeGeneratedExamples(generated.concat(parsed.questions || []));
async function readPromptPool() {
var cached = await opts.redisCache.getJson(redisAllKey).catch(function() { return null; });
if (cached && Array.isArray(cached.examples) && cached.examples.length) return cached;
var lastGood = await opts.redisCache.getJson(redisLastGoodKey).catch(function() { return null; });
if (lastGood && Array.isArray(lastGood.examples) && lastGood.examples.length) return lastGood;
if (typeof opts.loadStoredPromptPool === 'function') {
var stored = await opts.loadStoredPromptPool().catch(function() { return null; });
if (stored && Array.isArray(stored.examples) && stored.examples.length) {
await writePromptPool(stored, { skipStore: true }).catch(function() {});
return stored;
}
}
if (generated.length < 8) return fallbackIndexedExamples();
return generated;
return null;
}
async function collectPromptSeedSnippets() {
var seeds = promptSeedQueries();
async function buildCorpusPromptPool() {
var taxonomy = taxonomyWithQuotas(target);
var chatModel = await opts.getSetting('clinical_assistant.prompt_model', '') || process.env.CLINICAL_ASSISTANT_PROMPT_MODEL || await opts.getSetting('clinical_assistant.chat_model', '') || await opts.getSetting('models.default', '');
var generated = [];
for (var t = 0; t < taxonomy.length && generated.length < target; t++) {
var item = taxonomy[t];
var snippets = await collectPromptSeedSnippets(item);
if (!snippets.length) continue;
var categoryExamples = [];
var batches = Math.max(1, Math.min(5, Math.ceil(item.quota / 25)));
for (var i = 0; i < batches && categoryExamples.length < item.quota; i++) {
var batchSnippets = rotateExamples(snippets, i * 18).slice(0, 18);
var sourceText = batchSnippets.map(function(s, idx) {
return '[' + (idx + 1) + '] ' + s.title + (s.page ? ', page ' + s.page : '') + '\n' + clip(s.excerpt, 700);
}).join('\n\n');
var ai = await opts.callAI([
{
role: 'system',
content: 'Generate realistic pediatric clinical assistant starter questions from indexed source snippets. Use only the provided titles/snippets as inspiration. Do not answer the questions. Do not mention source names. Every question must be about children, neonates, infants, adolescents, pediatric dosing, pediatric diagnosis, pediatric management, pediatric red flags, pediatric counseling, or pediatric disposition. Return strict JSON only: {"questions":[{"label":"2-5 word label","prompt":"question ending with ?","category":"' + item.category + '","age_band":"' + item.ageBands.join('|') + '","intent":"diagnosis|management|red_flags|differential|counseling|dosing|admission|discharge|review"}]}.'
},
{ role: 'user', content: 'Category: ' + item.category + '\nAge bands: ' + item.ageBands.join(', ') + '\nIndexed pediatric source snippets:\n\n' + sourceText + '\n\nCreate 30 varied starter questions for this category. Balance emergencies, outpatient decisions, diagnostic workup, red flags, treatment, fluids, dosing, admission/discharge, and family counseling when relevant.' }
], {
model: chatModel || undefined,
temperature: 0.78,
maxTokens: 2600
});
var parsed = parseJsonObject(String(ai.content || ''));
categoryExamples = normalizeGeneratedExamples(categoryExamples.concat(parsed.questions || []), item).slice(0, item.quota);
}
generated = normalizeGeneratedExamples(generated.concat(categoryExamples));
}
if (generated.length < 8) return [];
return generated.slice(0, target);
}
async function collectPromptSeedSnippets(taxonomyItem) {
var seeds = promptSeedQueries(taxonomyItem);
var seen = new Set();
var snippets = [];
for (var i = 0; i < seeds.length && snippets.length < 120; i++) {
for (var i = 0; i < seeds.length && snippets.length < 36; i++) {
try {
var searchResponse = await opts.semanticSearch(seeds[i], { limit: 5, includeContext: true, contextChars: 700 });
var searchResponse = await opts.semanticSearch(seeds[i], { limit: 5, includeContext: true, contextChars: 900 });
opts.dedupeSources(opts.normalizeMcpSearchResponse(searchResponse)).forEach(function(source) {
var key = [source.title, source.page, source.chunk_index].join('|');
if (seen.has(key) || snippets.length >= 120) return;
if (seen.has(key) || snippets.length >= 36) return;
seen.add(key);
snippets.push(source);
});
} catch (e) {
if (snippets.length === 0) throw e;
continue;
}
}
return snippets;
}
function promptSeedQueries() {
function promptSeedQueries(taxonomyItem) {
var seeds = (opts.exampleCandidates || []).map(function(item) { return item.prompt; });
Object.keys(opts.topicSuggestions || {}).forEach(function(key) {
seeds = seeds.concat(opts.topicSuggestions[key]);
});
seeds = seeds.concat([
seeds = seeds.concat(taxonomyItem.seeds).concat([
taxonomyItem.category + ' pediatric ' + taxonomyItem.seeds.join(' '),
'pediatric emergency red flags admission criteria treatment dosing',
'neonatal fever jaundice respiratory distress vomiting dehydration',
'childhood infectious diseases empiric antibiotics workup disposition',
@ -130,14 +377,18 @@ function createClinicalPromptPool(opts) {
'pediatric gastrointestinal dehydration abdominal pain bilious vomiting',
'developmental milestones anemia endocrine neurologic pediatric review'
]);
return shuffle(seeds).slice(0, 30);
return shuffle(seeds).slice(0, 8);
}
function fallbackIndexedExamples() {
return typeof opts.getIndexedTopicExamples === 'function' ? opts.getIndexedTopicExamples().catch(function() { return []; }) : [];
}
function normalizeGeneratedExamples(items) {
async function getMeta() {
return await opts.redisCache.getJson(redisMetaKey).catch(function() { return null; });
}
function normalizeGeneratedExamples(items, defaultTaxonomy) {
var seen = new Set();
var out = [];
(Array.isArray(items) ? items : []).forEach(function(item) {
@ -146,16 +397,130 @@ function createClinicalPromptPool(opts) {
var key = prompt.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
if (seen.has(key)) return;
seen.add(key);
var category = sanitizeCategory(item.category, defaultTaxonomy);
var ageBand = sanitizeAgeBand(item.age_band || item.ageBand || item.age, TAXONOMY_BY_CATEGORY[category] || defaultTaxonomy, prompt);
out.push({
label: cleanExampleLabel(item.label || prompt),
prompt: prompt,
category: clip(String(item.category || 'general'), 40),
intent: clip(String(item.intent || 'review'), 30)
category: category,
age_band: ageBand,
intent: sanitizeIntent(item.intent)
});
});
return out.slice(0, target);
}
async function writePromptPool(payload, context) {
context = context || {};
var counts = buildPoolCounts(payload.examples);
var writes = [
opts.redisCache.setJson(redisAllKey, payload, ttlSeconds),
opts.redisCache.setJson(redisLastGoodKey, payload, 0),
opts.redisCache.setJson(redisMetaKey, {
generatedAt: payload.generatedAt,
target: target,
count: payload.examples.length,
categories: counts.categories,
age_bands: counts.age_bands,
intents: counts.intents
}, ttlSeconds)
];
Object.keys(counts.categoryExamples).forEach(function(category) {
writes.push(opts.redisCache.setJson(redisBaseKey + ':category:' + category, {
generatedAt: payload.generatedAt,
category: category,
examples: counts.categoryExamples[category]
}, ttlSeconds));
});
Object.keys(counts.ageExamples).forEach(function(ageBand) {
writes.push(opts.redisCache.setJson(redisBaseKey + ':age:' + ageBand, {
generatedAt: payload.generatedAt,
age_band: ageBand,
examples: counts.ageExamples[ageBand]
}, ttlSeconds));
});
Object.keys(counts.intentExamples).forEach(function(intent) {
writes.push(opts.redisCache.setJson(redisBaseKey + ':intent:' + intent, {
generatedAt: payload.generatedAt,
intent: intent,
examples: counts.intentExamples[intent]
}, ttlSeconds));
});
await Promise.all(writes);
if (!context.skipStore && typeof opts.savePromptPool === 'function') {
await opts.savePromptPool(payload, context).catch(function(e) {
console.warn('[clinical-assistant] prompt pool snapshot save failed:', e.message);
});
}
}
function buildPoolCounts(examples) {
var counts = { categories: {}, age_bands: {}, intents: {}, categoryExamples: {}, ageExamples: {}, intentExamples: {} };
examples.forEach(function(example) {
addCount(counts.categories, example.category);
addCount(counts.age_bands, example.age_band);
addCount(counts.intents, example.intent);
pushGroup(counts.categoryExamples, example.category, example);
pushGroup(counts.ageExamples, example.age_band, example);
pushGroup(counts.intentExamples, example.intent, example);
});
return counts;
}
function addCount(counts, key) {
counts[key] = (counts[key] || 0) + 1;
}
function pushGroup(groups, key, example) {
if (!groups[key]) groups[key] = [];
groups[key].push(example);
}
function taxonomyWithQuotas(total) {
var weightTotal = PEDIATRIC_TAXONOMY.reduce(function(sum, item) { return sum + item.weight; }, 0);
var assigned = 0;
var rows = PEDIATRIC_TAXONOMY.map(function(item) {
var exact = total * item.weight / weightTotal;
var quota = Math.max(10, Math.floor(exact));
assigned += quota;
return Object.assign({}, item, { quota: quota, remainder: exact - Math.floor(exact) });
}).sort(function(a, b) { return b.remainder - a.remainder; });
for (var i = 0; assigned < total; i = (i + 1) % rows.length) {
rows[i].quota += 1;
assigned += 1;
}
for (var j = rows.length - 1; assigned > total && j >= 0; j--) {
if (rows[j].quota <= 10) continue;
rows[j].quota -= 1;
assigned -= 1;
}
return rows.sort(function(a, b) {
return PEDIATRIC_TAXONOMY.indexOf(TAXONOMY_BY_CATEGORY[a.category]) - PEDIATRIC_TAXONOMY.indexOf(TAXONOMY_BY_CATEGORY[b.category]);
});
}
function sanitizeCategory(category, defaultTaxonomy) {
category = String(category || '').trim().toLowerCase().replace(/[\s/-]+/g, '_');
category = CATEGORY_ALIASES[category] || CATEGORY_ALIASES[category.replace(/_/g, ' ')] || category;
if (TAXONOMY_BY_CATEGORY[category]) return category;
return defaultTaxonomy && defaultTaxonomy.category || 'counseling';
}
function sanitizeIntent(intent) {
intent = String(intent || 'review').trim().toLowerCase().replace(/[\s/-]+/g, '_');
return VALID_INTENTS[intent] ? intent : 'review';
}
function sanitizeAgeBand(ageBand, taxonomyItem, prompt) {
ageBand = String(ageBand || '').trim().toLowerCase().replace(/[\s/-]+/g, '_');
if (VALID_AGE_BANDS[ageBand]) return ageBand;
if (/\b(neonate|newborn)\b/i.test(prompt)) return 'neonate';
if (/\b(infant|baby)\b/i.test(prompt)) return 'infant';
if (/\b(toddler)\b/i.test(prompt)) return 'toddler';
if (/\b(adolescent|teen)\b/i.test(prompt)) return 'adolescent';
return taxonomyItem && taxonomyItem.ageBands && taxonomyItem.ageBands[0] || 'school_age';
}
function isUsefulQuestion(prompt, item) {
if (!prompt || prompt.length < 25 || prompt.length > 220) return false;
if (prompt.indexOf('?') === -1) return false;
@ -165,9 +530,10 @@ function createClinicalPromptPool(opts) {
function hasPediatricSignal(prompt, item) {
if (/\b(child|children|pediatric|paediatric|infant|neonate|newborn|adolescent|teen|toddler|baby)\b/i.test(prompt)) return true;
var category = String(item && item.category || '').toLowerCase();
var intent = String(item && item.intent || '').toLowerCase();
return /\b(neonates?|development|respiratory|infectious disease|gi|neurology|cardiology|endocrine|hematology|toxicology|procedures|dosing\/fluids|red flags|differential|admission\/discharge|counseling|emergency|general)\b/.test(category) && /\b(diagnosis|management|red_flags|differential|counseling|dosing|admission|review)\b/.test(intent);
var category = sanitizeCategory(item && item.category, null);
var intent = sanitizeIntent(item && item.intent);
var ageBand = String(item && (item.age_band || item.ageBand || item.age) || '').toLowerCase().replace(/[\s/-]+/g, '_');
return !!TAXONOMY_BY_CATEGORY[category] && !!VALID_INTENTS[intent] && (!ageBand || !!VALID_AGE_BANDS[ageBand]);
}
function cleanExampleLabel(label) {
@ -205,8 +571,16 @@ function createClinicalPromptPool(opts) {
return {
getAvailableExamples: getAvailableExamples,
refreshIfNeeded: refreshIfNeeded
refreshIfNeeded: refreshIfNeeded,
getMeta: getMeta,
writePromptPool: writePromptPool
};
}
function nonNegativeInt(value, fallback) {
if (value == null || value === '') return fallback;
var n = Number(value);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
}
module.exports = { createClinicalPromptPool };

View file

@ -34,7 +34,11 @@ async function getJson(key) {
async function setJson(key, value, ttlSeconds) {
var redis = await getRedis();
if (!redis) return false;
await redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
if (ttlSeconds && Number(ttlSeconds) > 0) {
await redis.set(key, JSON.stringify(value), { EX: Math.floor(Number(ttlSeconds)) });
} else {
await redis.set(key, JSON.stringify(value));
}
return true;
}

View file

@ -7,6 +7,20 @@ function parseList(value) {
.filter(Boolean);
}
var KITTEN_TTS_VOICES = ['Bella', 'Jasper', 'Luna', 'Bruno', 'Rosie', 'Hugo', 'Kiki', 'Leo'];
var SUPERTONIC_TTS_VOICES = ['F1', 'F2', 'F3', 'F4', 'F5', 'M1', 'M2', 'M3', 'M4', 'M5'];
var GROQ_ORPHEUS_ENGLISH_VOICES = ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy'];
var GROQ_ORPHEUS_ARABIC_VOICES = ['abdullah', 'fahad', 'sultan', 'lulwa', 'noura', 'aisha'];
function uniqueList(values) {
var seen = new Set();
return (values || []).filter(function(value) {
if (!value || seen.has(value)) return false;
seen.add(value);
return true;
});
}
function getTTSProvider() {
var env = process.env.TTS_PROVIDER;
if (env === 'litellm') return 'litellm';
@ -20,10 +34,58 @@ function getTTSEnvProvider() {
function getTTSVoiceLists() {
return {
litellm: parseList(process.env.LITELLM_TTS_VOICES)
litellm: uniqueList(parseList(process.env.LITELLM_TTS_VOICES).concat(KITTEN_TTS_VOICES, SUPERTONIC_TTS_VOICES, GROQ_ORPHEUS_ENGLISH_VOICES, GROQ_ORPHEUS_ARABIC_VOICES))
};
}
function getLiteLLMTTSModelFamily(model) {
var id = String(model || '').toLowerCase();
if (id === 'local-kitten-tts') return 'kitten';
if (id === 'local-supertonic-tts') return 'supertonic';
if (id === 'local-kokoro-tts') return 'kokoro';
if (id === 'groq-orpheus-english' || id === 'canopylabs/orpheus-v1-english') return 'groq-orpheus-english';
if (id === 'groq-orpheus-arabic-saudi' || id === 'canopylabs/orpheus-arabic-saudi') return 'groq-orpheus-arabic';
return 'unknown';
}
function getLiteLLMTTSRequestOptions(model) {
var family = getLiteLLMTTSModelFamily(model);
if (family === 'groq-orpheus-english' || family === 'groq-orpheus-arabic') {
return { response_format: 'wav' };
}
return {};
}
function isLiteLLMTTSVoiceCompatible(model, voice) {
if (!voice) return true;
var family = getLiteLLMTTSModelFamily(model);
if (family === 'kitten') return KITTEN_TTS_VOICES.indexOf(voice) !== -1;
if (family === 'supertonic') return SUPERTONIC_TTS_VOICES.indexOf(voice) !== -1;
if (family === 'groq-orpheus-english') return GROQ_ORPHEUS_ENGLISH_VOICES.indexOf(String(voice).toLowerCase()) !== -1;
if (family === 'groq-orpheus-arabic') return GROQ_ORPHEUS_ARABIC_VOICES.indexOf(String(voice).toLowerCase()) !== -1;
if (family === 'kokoro') {
return KITTEN_TTS_VOICES.indexOf(voice) === -1 && SUPERTONIC_TTS_VOICES.indexOf(voice) === -1 &&
GROQ_ORPHEUS_ENGLISH_VOICES.indexOf(String(voice).toLowerCase()) === -1 && GROQ_ORPHEUS_ARABIC_VOICES.indexOf(String(voice).toLowerCase()) === -1;
}
return true;
}
function getLiteLLMTTSVoicesForModel(model, opts) {
opts = opts || {};
var family = getLiteLLMTTSModelFamily(model);
var voices = [];
if (family === 'kitten') voices = KITTEN_TTS_VOICES.slice();
else if (family === 'supertonic') voices = SUPERTONIC_TTS_VOICES.slice();
else if (family === 'groq-orpheus-english') voices = GROQ_ORPHEUS_ENGLISH_VOICES.slice();
else if (family === 'groq-orpheus-arabic') voices = GROQ_ORPHEUS_ARABIC_VOICES.slice();
else voices = parseList(process.env.LITELLM_TTS_VOICES);
[opts.currentVoice, process.env.LITELLM_TTS_VOICE].forEach(function(voice) {
if (isLiteLLMTTSVoiceCompatible(model, voice)) voices.push(voice);
});
return uniqueList(voices.filter(function(voice) { return isLiteLLMTTSVoiceCompatible(model, voice); }));
}
function isLiteLLMTTSModel(model) {
var mode = model && model.model_info && model.model_info.mode ? String(model.model_info.mode) : '';
return mode === 'audio_speech';
@ -56,6 +118,26 @@ function getLiteLLMTTSDiscoveryItems(models, opts) {
getTTSVoiceLists().litellm.forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: voice, source: 'configured-voice-list', kind: 'voice' });
});
if (getLiteLLMTTSModels(models).indexOf('local-kitten-tts') !== -1 || opts.currentModel === 'local-kitten-tts') {
KITTEN_TTS_VOICES.forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: 'Kitten ' + voice, source: 'local-kitten-tts', kind: 'voice' });
});
}
if (getLiteLLMTTSModels(models).indexOf('local-supertonic-tts') !== -1 || opts.currentModel === 'local-supertonic-tts') {
SUPERTONIC_TTS_VOICES.forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: 'Supertonic ' + voice, source: 'local-supertonic-tts', kind: 'voice' });
});
}
if (getLiteLLMTTSModels(models).indexOf('groq-orpheus-english') !== -1 || opts.currentModel === 'groq-orpheus-english') {
GROQ_ORPHEUS_ENGLISH_VOICES.forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: 'Groq Orpheus ' + voice, source: 'groq-orpheus-english', kind: 'voice' });
});
}
if (getLiteLLMTTSModels(models).indexOf('groq-orpheus-arabic-saudi') !== -1 || opts.currentModel === 'groq-orpheus-arabic-saudi') {
GROQ_ORPHEUS_ARABIC_VOICES.forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: 'Groq Orpheus Arabic ' + voice, source: 'groq-orpheus-arabic-saudi', kind: 'voice' });
});
}
return items;
}
@ -64,7 +146,10 @@ module.exports = {
getLiteLLMTTSDiscoveryItems,
getLiteLLMHeaders,
getLiteLLMTTSModels,
getLiteLLMTTSRequestOptions,
getLiteLLMTTSVoicesForModel,
getTTSProvider,
getTTSVoiceLists,
isLiteLLMTTSVoiceCompatible,
isLiteLLMTTSModel
};

View file

@ -14,13 +14,35 @@ test('clinical assistant starter prompts are Redis or indexed-source backed', ()
const pool = read('src/utils/clinicalPromptPool.js');
assert.doesNotMatch(route, /EXAMPLE_CANDIDATES|TOPIC_SUGGESTIONS|topic_suggestions|buildSuggestionAnswer/);
assert.match(route, /createClinicalPromptPool\(\{[\s\S]*redisCache: redisCache/);
assert.match(pool, /opts\.redisCache\.getJson\(redisKey\)/);
assert.match(pool, /opts\.redisCache\.setJson\(redisKey, payload, ttlSeconds\)/);
assert.match(pool, /CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 200/);
assert.match(pool, /Math\.min\(40, Math\.ceil\(target \/ 25\)\)/);
assert.match(pool, /rotateExamples\(snippets, i \* 24\)/);
assert.match(route, /isUsefulIndexedTopicExample/);
assert.match(pool, /clinical-assistant:prompt-pool:v2/);
assert.match(pool, /redisBaseKey \+ ':all'/);
assert.match(pool, /redisBaseKey \+ ':last-good'/);
assert.match(pool, /redisBaseKey \+ ':category:' \+ category/);
assert.match(pool, /redisBaseKey \+ ':age:' \+ ageBand/);
assert.match(pool, /redisBaseKey \+ ':intent:' \+ intent/);
assert.match(pool, /CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 1000/);
assert.match(pool, /CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 7 \* 24 \* 60 \* 60 \* 1000/);
assert.match(route, /loadStoredPromptPool: loadLatestPromptPoolSnapshot/);
assert.match(pool, /PEDIATRIC_TAXONOMY/);
assert.match(pool, /taxonomyWithQuotas\(target\)/);
assert.match(pool, /age_band: ageBand/);
assert.match(pool, /function hasPediatricSignal\(prompt, item\)/);
assert.match(pool, /var examples = await fallbackIndexedExamples\(\)/);
assert.doesNotMatch(pool, /taxonomyFallbackExamples|taxonomySupplementExamples/);
assert.doesNotMatch(pool, /return fallbackIndexedExamples\(\)/);
});
test('clinical assistant prompt pool can be regenerated by admins', () => {
const admin = read('src/routes/adminConfig.js');
const page = read('public/components/admin.html');
const js = read('public/js/admin.js');
assert.match(admin, /\/clinical-assistant\/prompt-pool\/regenerate/);
assert.match(admin, /\/clinical-assistant\/prompt-pool\/restore/);
assert.match(page, /btn-regenerate-assistant-prompt-pool/);
assert.match(page, /btn-restore-assistant-prompt-pool/);
assert.match(js, /regenerateAssistantPromptPool/);
assert.match(js, /restoreAssistantPromptPool/);
});
test('clinical assistant prompt forbids relabeling other sources as named sources', () => {

View file

@ -1,7 +1,7 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const ENV_KEYS = ['TTS_PROVIDER', 'LITELLM_API_BASE', 'LITELLM_API_KEY', 'LITELLM_MASTER_KEY', 'LITELLM_TTS_VOICES'];
const ENV_KEYS = ['TTS_PROVIDER', 'LITELLM_API_BASE', 'LITELLM_API_KEY', 'LITELLM_MASTER_KEY', 'LITELLM_TTS_VOICES', 'LITELLM_TTS_VOICE'];
function withEnv(overrides, fn) {
const previous = {};
@ -36,10 +36,11 @@ test('TTS provider ignores non-LiteLLM provider overrides', () => {
test('LiteLLM TTS voice list comes from configured voice catalog', () => {
const ttsProvider = require('../src/utils/ttsProvider');
withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam, sherpa/kokoro:af_bella' }, () => {
assert.deepEqual(ttsProvider.getTTSVoiceLists().litellm, [
'sherpa/kokoro:am_adam',
'sherpa/kokoro:af_bella'
]);
const voices = ttsProvider.getTTSVoiceLists().litellm;
assert.equal(voices[0], 'sherpa/kokoro:am_adam');
assert.equal(voices[1], 'sherpa/kokoro:af_bella');
assert.equal(voices.includes('Jasper'), true);
assert.equal(voices.includes('F1'), true);
});
});
@ -92,6 +93,19 @@ test('LiteLLM TTS discovery includes metadata models and configured fallbacks',
});
});
test('LiteLLM TTS discovery expands Kitten and Supertonic voices', () => {
const ttsProvider = require('../src/utils/ttsProvider');
withEnv({}, () => {
const items = ttsProvider.getLiteLLMTTSDiscoveryItems([
{ model_name: 'local-kitten-tts', model_info: { mode: 'audio_speech' } },
{ model_name: 'local-supertonic-tts', model_info: { mode: 'audio_speech' } }
], {});
assert.equal(items.some(function(item) { return item.id === 'local-kitten-tts' && item.kind === 'model'; }), true);
assert.equal(items.some(function(item) { return item.id === 'Jasper' && item.kind === 'voice'; }), true);
assert.equal(items.some(function(item) { return item.id === 'F1' && item.kind === 'voice'; }), true);
});
});
test('LiteLLM TTS discovery still shows configured model if metadata lookup fails', () => {
const ttsProvider = require('../src/utils/ttsProvider');
assert.deepEqual(ttsProvider.getLiteLLMTTSDiscoveryItems([], {
@ -103,6 +117,38 @@ test('LiteLLM TTS discovery still shows configured model if metadata lookup fail
]);
});
test('LiteLLM TTS voices are scoped to the active local model', () => {
const ttsProvider = require('../src/utils/ttsProvider');
withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella', LITELLM_TTS_VOICE: 'sherpa/kokoro:am_adam' }, () => {
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-kitten-tts'), ['Bella', 'Jasper', 'Luna', 'Bruno', 'Rosie', 'Hugo', 'Kiki', 'Leo']);
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-supertonic-tts'), ['F1', 'F2', 'F3', 'F4', 'F5', 'M1', 'M2', 'M3', 'M4', 'M5']);
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-kokoro-tts'), ['sherpa/kokoro:am_adam', 'sherpa/kokoro:af_bella']);
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('groq-orpheus-english'), ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy']);
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('canopylabs/orpheus-arabic-saudi'), ['abdullah', 'fahad', 'sultan', 'lulwa', 'noura', 'aisha']);
});
});
test('LiteLLM TTS compatibility rejects cross-model local voices', () => {
const ttsProvider = require('../src/utils/ttsProvider');
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kokoro-tts', 'Bella'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kokoro-tts', 'M1'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kitten-tts', 'Bella'), true);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kitten-tts', 'M1'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-supertonic-tts', 'M1'), true);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-supertonic-tts', 'Bella'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-english', 'hannah'), true);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-english', 'sherpa/kokoro:af_bella'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'aisha'), true);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'troy'), false);
});
test('Groq Orpheus TTS requests force wav response format', () => {
const ttsProvider = require('../src/utils/ttsProvider');
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('groq-orpheus-english'), { response_format: 'wav' });
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('canopylabs/orpheus-arabic-saudi'), { response_format: 'wav' });
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('local-kokoro-tts'), {});
});
test('LiteLLM headers include optional content type without exposing key value', () => {
const ttsProvider = require('../src/utils/ttsProvider');
withEnv({ LITELLM_API_KEY: 'secret-token' }, () => {