Compare commits

..

58 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
137 changed files with 5336 additions and 11104 deletions

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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

View file

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

4
.gitignore vendored
View file

@ -41,7 +41,3 @@ e2e/playwright-report/
.firecrawl/
# Refactored test stack stays local for now
.env.refactored
.env.refactored.example
docker-compose.refactored.yml
refactored-ped-ai/

View file

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

View file

@ -11,6 +11,7 @@ The app runs as an authenticated Express/Postgres service with a browser fronten
- Live encounter capture with structured pediatric HPI generation.
- Dictation cleanup for narrative notes.
- SOAP, sick visit, well visit, hospital course, chart review, precharting, and ED encounter workflows.
- Parent-facing education handouts generated from clinician notes, with diagnosis, medication, emergency-care guidance, and preferred-language support.
- Pediatric developmental milestone tooling.
- Templates, physician memory, and per-tab model overrides.
- Server-side speech-to-text routing through configured providers.
@ -153,6 +154,11 @@ npm run e2e
Primary references:
- `docs/ARCHITECTURE.md` for the current system map and service boundaries.
- `docs/DEVELOPMENT.md` for day-to-day code-change workflow.
- `docs/SCALING.md` for scaling priorities and readiness work.
- `docs/CLINICAL_ASSISTANT.md` for MCP-backed assistant behavior and safety rules.
- `docs/MODULE_CONVENTIONS.md` for CommonJS, ESM, globals, and rendering rules.
- `docs/architecture.md` for high-level architecture.
- `docs/api-reference.md` for API routes.
- `docs/authentication.md` for auth, OIDC, and security configuration.

View file

@ -10,6 +10,12 @@ services:
CLINICAL_ASSISTANT_MCP_URL: http://mcp:8000/mcp
REDIS_URL: redis://ped-ai-redis:6379
LOKI_URL: http://monitoring-loki:3100
LITELLM_API_BASE: http://litellm:4000
TTS_PROVIDER: litellm
LITELLM_TTS_MODEL: local-kokoro-tts
LITELLM_TTS_VOICE: sherpa/kokoro:am_adam
LITELLM_TTS_VOICES: sherpa/kokoro:am_adam,sherpa/kokoro:am_michael,sherpa/kokoro:af_bella,sherpa/kokoro:af_nicole,sherpa/kokoro:bf_emma,sherpa/kokoro:bm_lewis
CLINICAL_ASSISTANT_PROMPT_POOL_TARGET: 1000
volumes:
- scribe-logs:/app/data/logs
- clinical-assistant-mcp-data:/app/mcp-data:ro
@ -22,8 +28,9 @@ services:
container_name: pediatric-ai-scribe
networks:
- default
- mcp-server_default
- monitoring_default
- danvics_mcp
- danvics_monitoring
- danvics_speech
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s
@ -39,7 +46,7 @@ services:
environment:
POSTGRES_DB: pedscribe
POSTGRES_USER: pedscribe
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
POSTGRES_PASSWORD: ${DB_PASSWORD:-pedscribe}
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
@ -65,7 +72,7 @@ services:
retries: 5
networks:
- default
- mcp-server_default
- danvics_mcp
volumes:
pgdata:
@ -76,7 +83,9 @@ volumes:
name: mcp-server_mcp-data
networks:
mcp-server_default:
danvics_mcp:
external: true
monitoring_default:
danvics_monitoring:
external: true
danvics_speech:
external: true

90
docs/ARCHITECTURE.md Normal file
View file

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

View file

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

103
docs/DEVELOPMENT.md Normal file
View file

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

View file

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

119
docs/SCALING.md Normal file
View file

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

View file

@ -1,13 +1,18 @@
# AI providers
All AI calls flow through `callAI(messages, options)` in `src/utils/ai.js`.
Provider is selected once at startup and is transparent to callers.
Provider is selected at startup and is transparent to route handlers.
## Provider selection
1. If `AI_PROVIDER` env var is set, use it.
2. Otherwise, check credentials in priority order:
`bedrock > azure > vertex > litellm > openrouter`.
1. If `AI_PROVIDER` is set, it chooses `bedrock`, `azure`, `vertex`,
`litellm`, or `openrouter` explicitly.
2. If `AI_PROVIDER` is unset, `ai.js` initializes every configured client and
the last configured non-OpenRouter provider wins in current load order:
Bedrock → Azure → Vertex → LiteLLM. If none of those are configured,
OpenRouter is the default.
3. If the selected provider cannot initialize, the code falls back to
OpenRouter and surfaces an error if `OPENROUTER_API_KEY` is missing.
## Providers
@ -124,9 +129,11 @@ Applied to: `soap.js`, `hpi.js`, `refine.js`, `sickVisit.js`, `wellVisit.js`,
### Physician memories
Saved corrections are injected into prompts as `[STYLE HINTS (low priority)]`
with 200-character snippets. The low-priority wording prevents smaller models
from hallucinating content from the correction examples into the current note.
Saved templates and prompt preferences are injected into prompts as
`[STYLE HINTS (low priority)]` when they belong to AI-context categories. The
low-priority wording prevents smaller models from hallucinating content from a
stored template into the current note. `custom` memories and legacy
`correction_*` rows are not prompt context.
## API call logging

View file

@ -1,6 +1,6 @@
# API Reference
Complete endpoint reference for the PedAI application. All endpoints are prefixed with the application base URL. Unless noted otherwise, authenticated endpoints require a valid JWT token passed via cookie or `Authorization: Bearer <token>` header.
Working endpoint reference for the main PedAI flows. It covers the clinical, auth, Learning Hub, user data, and admin surfaces most commonly used by the frontend, but the source of truth is still `server.js` plus `src/routes/*.js`. Unless noted otherwise, authenticated endpoints require a valid web cookie or `Authorization: Bearer <token>` header.
---
@ -36,7 +36,7 @@ Base path: `/api/auth/`
### POST /api/auth/register
Register a new user account. The first user to register is automatically assigned the admin role. Requires Cloudflare Turnstile verification. A verification email is sent on success.
Register a new user account. The first user to register is automatically assigned the admin role. Turnstile is required only when `TURNSTILE_SECRET_KEY` is configured. If SMTP is configured, the response asks the user to verify email; without SMTP, the account is auto-verified and logged in.
- **Auth required:** No
- **Request body:**
@ -52,7 +52,8 @@ Register a new user account. The first user to register is automatically assigne
```json
{
"success": true,
"message": "Registration successful. Please check your email to verify your account."
"needsVerification": true,
"message": "Check your email for verification link."
}
```
@ -67,7 +68,7 @@ Verify a user's email address via the link sent during registration.
| Parameter | Type | Description |
|-----------|--------|--------------------------------------|
| `token` | string | Email verification token from the link |
- **Response:** Redirects to the login page with a success or error message.
- **Response:** HTML status page for success or expired/invalid token.
---
@ -102,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:**
@ -111,11 +111,14 @@ Authenticate a user. Supports optional TOTP two-factor authentication. On succes
{
"success": true,
"token": "jwt-string",
"sessionId": "string",
"user": {
"id": "number",
"name": "string",
"email": "string",
"role": "admin | moderator | user"
"role": "admin | moderator | user",
"totp_enabled": "boolean",
"email_verified": "boolean"
}
}
```
@ -137,6 +140,7 @@ Generate a TOTP secret and QR code for setting up two-factor authentication.
- **Response:**
```json
{
"success": true,
"secret": "string",
"qrCode": "string (data URI)"
}
@ -159,7 +163,7 @@ Verify a TOTP code and enable two-factor authentication for the user.
```json
{
"success": true,
"message": "2FA enabled successfully."
"backupCodes": ["string"]
}
```
@ -179,8 +183,7 @@ Disable two-factor authentication. Requires password confirmation.
- **Response:**
```json
{
"success": true,
"message": "2FA disabled successfully."
"success": true
}
```
@ -202,7 +205,7 @@ Initiate password reset. Sends a reset link to the user's email.
```json
{
"success": true,
"message": "If an account exists with that email, a reset link has been sent."
"message": "If account exists, reset email sent"
}
```
@ -217,14 +220,14 @@ Reset the user's password using a valid reset token.
```json
{
"token": "string",
"password": "string"
"newPassword": "string"
}
```
- **Response:**
```json
{
"success": true,
"message": "Password reset successfully."
"passwordWarning": "string (optional)"
}
```
@ -232,15 +235,14 @@ Reset the user's password using a valid reset token.
### POST /api/auth/logout
Log out the current user by clearing the JWT cookie.
Log out the current user by deleting the current session row when a token is present and clearing the auth cookie.
- **Auth required:** No
- **Request body:** None
- **Response:**
```json
{
"success": true,
"message": "Logged out."
"success": true
}
```
@ -254,11 +256,15 @@ Retrieve the currently authenticated user's profile.
- **Response:**
```json
{
"id": "number",
"name": "string",
"email": "string",
"role": "admin | moderator | user",
"twoFactorEnabled": "boolean"
"user": {
"id": "number",
"name": "string",
"email": "string",
"role": "admin | moderator | user",
"totp_enabled": "boolean",
"email_verified": "boolean",
"canLocalAuth": "boolean"
}
}
```
@ -272,7 +278,7 @@ Check whether new user registration is enabled on this instance.
- **Response:**
```json
{
"enabled": true
"registrationEnabled": true
}
```
@ -337,7 +343,7 @@ Generate a History of Present Illness note from a patient encounter transcript.
"physicianMemories": "string (optional)"
}
```
- **Response:** Streamed text (text/event-stream) or JSON with the generated HPI note.
- **Response:** JSON with `success`, generated `hpi`, and resolved `model`.
---
@ -357,7 +363,7 @@ Generate an HPI note from a physician's dictated summary (same parameters as enc
"physicianMemories": "string (optional)"
}
```
- **Response:** Streamed text (text/event-stream) or JSON with the generated HPI note.
- **Response:** JSON with `success`, generated `hpi`, and resolved `model`.
---
@ -378,7 +384,7 @@ Generate a SOAP note from a patient encounter transcript.
"physicianMemories": "string (optional)"
}
```
- **Response:** Streamed text (text/event-stream) or JSON with the generated SOAP note.
- **Response:** JSON with `success`, generated `soap`, and resolved `model`.
---
@ -395,7 +401,7 @@ Generate a chart review summary from clinical notes.
"model": "string"
}
```
- **Response:** Streamed text or JSON with the chart review.
- **Response:** JSON with `success`, generated chart review content, and resolved `model`.
---
@ -415,7 +421,7 @@ Generate a hospital course summary from clinical notes.
"physicianMemories": "string (optional)"
}
```
- **Response:** Streamed text or JSON with the hospital course summary.
- **Response:** JSON with `success`, generated summary, and resolved `model`.
---
@ -432,27 +438,59 @@ Generate a SHADESS (Strengths, Home, Activities, Drugs, Emotions, Sexuality, Saf
"model": "string"
}
```
- **Response:** Streamed text or JSON with the SHADESS assessment.
- **Response:** JSON with `success`, generated assessment, and resolved `model`.
---
### POST /api/generate-sick-visit
### POST /api/sick-visit/note
Generate a sick visit note from a transcript, incorporating diagnosis information.
Generate a sick visit note from chief complaint, transcript/dictation, ROS/PE, and diagnosis context.
- **Auth required:** Yes
- **Request body:**
```json
{
"transcript": "string",
"chiefComplaint": "string",
"transcript": "string (optional)",
"dictation": "string (optional)",
"patientAge": "string",
"patientGender": "string",
"model": "string",
"diagnoses": "string | array",
"ros": "string (optional)",
"physicalExam": "string (optional)",
"diagnoses": "string (optional)",
"physicianMemories": "string (optional)"
}
```
- **Response:** Streamed text or JSON with the sick visit note.
- **Response:** JSON with `success`, generated note, and resolved `model`.
---
### POST /api/patient-education
Generate a parent-facing education handout from an existing clinician note. The frontend exposes this as the Handout action beside generated notes.
- **Auth required:** Yes
- **Request body:**
```json
{
"noteText": "string",
"diagnosis": "string (optional)",
"medications": "string (optional)",
"patientAge": "string (optional)",
"language": "string (optional, defaults to English)",
"readingLevel": "string (optional)",
"model": "string (optional)"
}
```
- **Response:**
```json
{
"success": true,
"handout": "plain-text parent handout",
"model": "string"
}
```
---
@ -470,7 +508,7 @@ Generate a developmental milestone narrative from milestone data.
"model": "string"
}
```
- **Response:** Streamed text or JSON with the milestone narrative.
- **Response:** JSON with `success`, generated narrative, and resolved `model`.
---
@ -486,7 +524,7 @@ Generate a concise summary from a previously generated milestone narrative.
"model": "string"
}
```
- **Response:** Streamed text or JSON with the milestone summary.
- **Response:** JSON with `success`, generated summary, and resolved `model`.
---
@ -498,12 +536,13 @@ Refine existing clinical text according to provided instructions.
- **Request body:**
```json
{
"text": "string",
"currentDocument": "string",
"instructions": "string",
"sourceContext": "string (optional)",
"model": "string"
}
```
- **Response:** Streamed text or JSON with the refined text.
- **Response:** JSON with `success`, refined text, and resolved `model`.
---
@ -515,11 +554,11 @@ Shorten a block of clinical text while preserving key information.
- **Request body:**
```json
{
"text": "string",
"document": "string",
"model": "string"
}
```
- **Response:** Streamed text or JSON with the shortened text.
- **Response:** JSON with `success`, shortened text, and resolved `model`.
---
@ -531,11 +570,12 @@ Improve clarity and readability of clinical text.
- **Request body:**
```json
{
"text": "string",
"document": "string",
"context": "string (optional)",
"model": "string"
}
```
- **Response:** Streamed text or JSON with the clarified text.
- **Response:** JSON with `success`, clarified text, and resolved `model`.
---
@ -550,7 +590,7 @@ Check whether speech-to-text transcription is available and which provider is co
```json
{
"available": true,
"provider": "whisper | deepgram | browser"
"provider": "litellm | none"
}
```
@ -630,20 +670,27 @@ List available AI models for the current instance.
### GET /api/encounters/saved
List all saved encounters for the authenticated user.
List active, unexpired saved encounters for the authenticated user.
- **Auth required:** Yes
- **Response:**
```json
[
{
"id": "number",
"label": "string",
"enc_type": "string",
"created_at": "string (ISO 8601)",
"updated_at": "string (ISO 8601)"
}
]
{
"success": true,
"encounters": [
{
"id": "number",
"label": "string",
"enc_type": "string",
"status": "string",
"created_at": "string (ISO 8601)",
"updated_at": "string (ISO 8601)",
"expires_at": "string (ISO 8601)",
"transcript_preview": "string",
"note_preview": "string"
}
]
}
```
---
@ -660,14 +707,17 @@ Retrieve a single saved encounter with full data.
- **Response:**
```json
{
"id": "number",
"label": "string",
"enc_type": "string",
"transcript": "string",
"generated_note": "string",
"partial_data": "object | null",
"created_at": "string (ISO 8601)",
"updated_at": "string (ISO 8601)"
"success": true,
"encounter": {
"id": "number",
"label": "string",
"enc_type": "string",
"transcript": "string",
"generated_note": "string",
"partial_data": "stringified JSON",
"created_at": "string (ISO 8601)",
"updated_at": "string (ISO 8601)"
}
}
```
@ -675,25 +725,29 @@ Retrieve a single saved encounter with full data.
### POST /api/encounters/saved
Create or update a saved encounter. Uses `idempotency_key` to prevent duplicates.
Create or update a saved encounter. Uses `idempotency_key` to prevent duplicates on create and `expected_version` for optimistic locking on updates.
- **Auth required:** Yes
- **Request body:**
```json
{
"id": "number (optional, update existing encounter)",
"label": "string",
"enc_type": "string",
"transcript": "string",
"generated_note": "string",
"partial_data": "object (optional)",
"idempotency_key": "string (optional)"
"status": "string (optional)",
"idempotency_key": "string (optional)",
"expected_version": "number (optional, update only)"
}
```
- **Response:**
```json
{
"success": true,
"id": "number"
"id": "number",
"version": "number (updates only)"
}
```
@ -719,7 +773,7 @@ Delete a saved encounter.
## Memories
Physician memories are reusable context snippets (preferences, style corrections, common instructions) injected into AI generation prompts.
Physician memories are encrypted user templates and prompt preferences. Only AI-context categories are injected into generation prompts; `custom` and legacy `correction_*` rows are excluded from `/api/memories/context`.
### GET /api/memories
@ -728,15 +782,19 @@ List all memories for the authenticated user. Returns up to 200 entries.
- **Auth required:** Yes
- **Response:**
```json
[
{
"id": "number",
"category": "string",
"name": "string",
"content": "string",
"created_at": "string (ISO 8601)"
}
]
{
"success": true,
"memories": [
{
"id": "number",
"category": "string",
"name": "string",
"content": "string",
"created_at": "string (ISO 8601)",
"updated_at": "string (ISO 8601)"
}
]
}
```
---
@ -758,7 +816,9 @@ Create a new memory.
```json
{
"success": true,
"id": "number"
"id": "number",
"originalSize": "number",
"compressedSize": "number"
}
```
@ -810,36 +870,15 @@ Delete a memory.
### GET /api/memories/context
Retrieve all memories formatted for injection into AI generation prompts.
Retrieve AI-context template/preference rows formatted for injection into AI
generation prompts. `custom` rows and legacy `correction_*` rows are excluded.
- **Auth required:** Yes
- **Response:**
```json
{
"context": "string"
}
```
---
### POST /api/memories/correction
Automatically save a style correction as a memory. Used when the physician corrects AI output.
- **Auth required:** Yes
- **Request body:**
```json
{
"section": "string",
"original_snippet": "string",
"corrected_snippet": "string"
}
```
- **Response:**
```json
{
"success": true,
"id": "number"
"success": true,
"context": "string"
}
```
@ -847,11 +886,11 @@ Automatically save a style correction as a memory. Used when the physician corre
## Audio Backups
Temporary audio backup storage with automatic 24-hour expiry.
Temporary encrypted audio backup storage with automatic 24-hour expiry.
### POST /api/audio-backups
Upload an audio backup. The file is gzip-compressed on the server.
Upload an audio backup. The file is gzip-compressed and encrypted on the server.
- **Auth required:** Yes
- **Content-Type:** `multipart/form-data`
@ -871,19 +910,25 @@ Upload an audio backup. The file is gzip-compressed on the server.
### GET /api/audio-backups
List all audio backups for the authenticated user.
List unexpired audio backups for the authenticated user.
- **Auth required:** Yes
- **Response:**
```json
[
{
"id": "number",
"filename": "string",
"created_at": "string (ISO 8601)",
"expires_at": "string (ISO 8601)"
}
]
{
"success": true,
"backups": [
{
"id": "number",
"module": "string",
"mime_type": "string",
"size_bytes": "number",
"compressed_bytes": "number",
"created_at": "string (ISO 8601)",
"expires_at": "string (ISO 8601)"
}
]
}
```
---
@ -930,15 +975,20 @@ List all documents for the authenticated user.
- **Auth required:** Yes
- **Response:**
```json
[
{
"id": "number",
"filename": "string",
"mimetype": "string",
"size": "number",
"created_at": "string (ISO 8601)"
}
]
{
"success": true,
"s3_configured": true,
"documents": [
{
"id": "number",
"filename": "string",
"mime_type": "string",
"size_bytes": "number",
"description": "string",
"created_at": "string (ISO 8601)"
}
]
}
```
---
@ -950,7 +1000,7 @@ Upload a document to S3 storage.
- **Auth required:** Yes
- **Content-Type:** `multipart/form-data`
- **File size limit:** 10 MB
- **Allowed types:** PDF, images (JPEG, PNG, GIF, WebP), Word documents (.doc, .docx), plain text (.txt), CSV (.csv)
- **Allowed types:** PDF, images (JPEG, PNG, GIF), Word documents (.doc, .docx), plain text (.txt), CSV (.csv)
- **Form fields:**
| Field | Type | Description |
|--------|------|---------------------|
@ -975,7 +1025,13 @@ Download a document.
| Parameter | Type | Description |
|-----------|--------|---------------|
| `id` | number | Document ID |
- **Response:** Binary file stream with appropriate content-type and content-disposition headers.
- **Response:** JSON containing a short-lived pre-signed download URL.
```json
{
"success": true,
"url": "string"
}
```
---
@ -1007,6 +1063,7 @@ Get the authenticated user's preferences.
- **Response:**
```json
{
"success": true,
"stt_model": "string",
"tts_voice": "string"
}
@ -1043,12 +1100,15 @@ List available STT models and TTS voices that the user can choose from.
- **Response:**
```json
{
"success": true,
"sttModels": [
{ "id": "string", "name": "string" }
{ "value": "string", "label": "string" }
],
"ttsVoices": [
{ "id": "string", "name": "string" }
]
{ "value": "string", "label": "string" }
],
"sttProvider": "string",
"ttsProvider": "string"
}
```
@ -1231,25 +1291,69 @@ Export active entries as `pedscribe-extensions.zip`. The ZIP contains `pedscribe
### POST /api/extensions/import
Import entries from a JSON request body. Exact duplicates already owned by the user are skipped.
Import entries from a JSON request body. Exact active duplicates are skipped. Exact trashed matches can be restored when requested. Possible duplicates are identified by loose matching (`location + number` or `number + type`) and skipped unless explicitly imported.
- **Auth required:** Yes
- **Request body:** Export payload object or an array of entry objects.
- **Request body:** Export payload object, an array of entry objects, or an object with `items` and `options`.
```json
{
"items": [
{
"location": "ED",
"name": "Charge Nurse",
"number": "1234",
"type": "extension",
"notes": "Optional note"
}
],
"options": {
"restoreTrashed": true,
"importPossibleDuplicates": false
}
}
```
- **Response:**
```json
{
"success": true,
"imported": 2,
"restored": 1,
"skipped": 1,
"possibleSkipped": 1,
"total": 3
}
```
---
### POST /api/extensions/import/preview
Preview a JSON import before committing it. The preview categorizes entries as new, exact active duplicates, exact trashed matches, or possible duplicates.
- **Auth required:** Yes
- **Request body:** Same JSON shapes accepted by `POST /api/extensions/import`.
- **Response:**
```json
{
"success": true,
"preview": {
"summary": {
"total": 3,
"new": 1,
"exactActive": 1,
"exactTrashed": 0,
"possible": 1
},
"entries": []
}
}
```
---
### POST /api/extensions/import-file
Import entries from a multipart file upload. Accepts a PedScribe JSON export or a ZIP export containing JSON.
Import entries from a multipart file upload. Accepts a PedScribe JSON export or a ZIP export containing JSON. Form fields `restoreTrashed=true` and `importPossibleDuplicates=true` control the same duplicate handling options as JSON import.
- **Auth required:** Yes
- **Request body:** `multipart/form-data` with field `file`.
@ -1258,6 +1362,16 @@ Import entries from a multipart file upload. Accepts a PedScribe JSON export or
---
### POST /api/extensions/import-file/preview
Preview a multipart JSON or ZIP import before committing it.
- **Auth required:** Yes
- **Request body:** `multipart/form-data` with field `file`.
- **Response:** Same preview shape as `POST /api/extensions/import/preview`.
---
## Nextcloud Integration
### POST /api/nextcloud/connect
@ -1806,17 +1920,21 @@ List all registered users.
- **Auth required:** Yes (admin)
- **Response:**
```json
[
{
"id": "number",
"name": "string",
"email": "string",
"role": "string",
"verified": "boolean",
"disabled": "boolean",
"created_at": "string (ISO 8601)"
}
]
{
"success": true,
"users": [
{
"id": "number",
"name": "string",
"email": "string",
"role": "string",
"email_verified": "boolean",
"totp_enabled": "boolean",
"disabled": "boolean",
"created_at": "string (ISO 8601)"
}
]
}
```
---
@ -1833,16 +1951,17 @@ Get detailed information and usage statistics for a specific user.
- **Response:**
```json
{
"id": "number",
"name": "string",
"email": "string",
"role": "string",
"verified": "boolean",
"disabled": "boolean",
"stats": {
"totalGenerations": "number",
"totalTranscriptions": "number",
"lastActive": "string (ISO 8601)"
"success": true,
"user": {
"id": "number",
"name": "string",
"email": "string",
"role": "string",
"email_verified": "boolean",
"totp_enabled": "boolean",
"disabled": "boolean",
"api_calls": "number",
"last_login": "string (ISO 8601) | null"
}
}
```
@ -1909,13 +2028,16 @@ Requires admin role unless noted otherwise.
### GET /api/admin/config/announcement
Get the current announcement banner. This endpoint is public and does not require authentication.
Get the current announcement banner. Any authenticated user can read this endpoint.
- **Auth required:** No
- **Auth required:** Yes
- **Response:**
```json
{
"announcement": "string | null"
"success": true,
"enabled": "boolean",
"text": "string",
"type": "info | warning | error | success"
}
```
@ -1930,12 +2052,17 @@ Get all application configuration settings.
---
### POST /api/admin/config
### PUT /api/admin/config/:key
Update application configuration settings.
Update one application configuration setting. The key must use an allowed prefix such as `announcement.`, `feature.`, `email.`, `prompt.`, `registration_enabled`, `site.`, `smtp.`, `models.`, `tts.`, `stt.`, `embeddings.`, or `clinical_assistant.`.
- **Auth required:** Yes (admin)
- **Request body:** Object with configuration key-value pairs to update.
- **Request body:**
```json
{
"value": "string"
}
```
- **Response:**
```json
{
@ -1975,21 +2102,6 @@ Get all AI prompt templates, including any admin overrides.
---
### POST /api/admin/config/prompts
Save AI prompt template overrides.
- **Auth required:** Yes (admin)
- **Request body:** Object mapping prompt keys to new values.
- **Response:**
```json
{
"success": true
}
```
---
### POST /api/admin/config/prompts/:key/reset
Reset a specific AI prompt template back to its default value.
@ -2032,13 +2144,16 @@ Check the current SMTP configuration status.
{
"configured": "boolean",
"host": "string",
"port": "number"
"port": "string",
"user": "string",
"from": "string",
"source": "env | database | none"
}
```
---
### POST /api/admin/config/smtp/update
### PUT /api/admin/config/smtp
Update SMTP email configuration.
@ -2048,9 +2163,10 @@ Update SMTP email configuration.
{
"host": "string",
"port": "number",
"username": "string",
"password": "string",
"from": "string"
"user": "string",
"pass": "string",
"from": "string",
"secure": "boolean"
}
```
- **Response:**
@ -2062,7 +2178,7 @@ Update SMTP email configuration.
---
### POST /api/admin/config/registration
### POST /api/admin/settings/registration
Update registration settings (enable/disable new registrations).
@ -2082,7 +2198,7 @@ Update registration settings (enable/disable new registrations).
---
### GET /api/admin/config/oidc
### GET /api/auth/oidc/config
Get the current OIDC/SSO configuration.
@ -2090,17 +2206,21 @@ Get the current OIDC/SSO configuration.
- **Response:**
```json
{
"enabled": "boolean",
"clientId": "string",
"issuerUrl": "string",
"buttonLabel": "string",
"disableLocalAuth": "boolean"
"success": true,
"config": {
"oidc.enabled": "string",
"oidc.issuer": "string",
"oidc.client_id": "string",
"oidc.client_secret": "masked string",
"oidc.disable_local_auth": "string",
"oidc.button_label": "string"
}
}
```
---
### POST /api/admin/config/oidc
### PUT /api/auth/oidc/config
Update the OIDC/SSO configuration.
@ -2108,12 +2228,12 @@ Update the OIDC/SSO configuration.
- **Request body:**
```json
{
"enabled": "boolean",
"clientId": "string",
"clientSecret": "string",
"issuerUrl": "string",
"buttonLabel": "string",
"disableLocalAuth": "boolean"
"oidc.enabled": "string",
"oidc.issuer": "string",
"oidc.client_id": "string",
"oidc.client_secret": "string",
"oidc.disable_local_auth": "string",
"oidc.button_label": "string"
}
```
- **Response:**
@ -2216,6 +2336,7 @@ Discover available models from the configured AI provider.
- **Response:**
```json
{
"success": true,
"models": [
{
"id": "string",
@ -2323,9 +2444,7 @@ Application health check endpoint.
- **Response:**
```json
{
"status": "ok",
"provider": "string",
"uptime": "number (seconds)"
"ok": true
}
```

View file

@ -48,9 +48,9 @@ src/
logger.js # audit/api/access + Loki shipper
errors.js # generic 500 responder
models.js, prompts.js, ai.js # AI provider + model + prompt management
embeddings.js # Vertex / LiteLLM / OpenAI embeddings
transcribe*.js, tts*.js # STT / TTS provider clients
routes/ # 27 Express routers (auth, hpi, soap, …)
embeddings.js # LiteLLM embeddings
transcribe.js, tts.js # LiteLLM STT / TTS routes
routes/ # Express routers (auth, hpi, soap, patient education, …)
public/ # SPA
index.html # shell, loads components on demand
@ -64,9 +64,13 @@ mobile/ # Capacitor wrapper
src/ # launcher (server-URL picker)
android/ # generated AS project + native Java
.forgejo/workflows/
android-apk.yml # signed APK on tag push; optional Play upload
docker-build.yml # Forgejo registry Docker image build
.github/workflows/
auto-version.yml # conventional-commits → semver bump → tag
android-release.yml # signed APK on tag push
android-release.yml # legacy GitHub tag APK release path
docker-publish.yml # multi-arch image on tag push
version-bump.yml # manual dispatch override
build-apk.yml # legacy TWA APK
@ -82,7 +86,7 @@ request
→ express.json (10 MB cap)
→ rate limiters (general 200 req/min, per-endpoint tighter on auth)
→ static (public/ with no-cache on HTML, 1h on JS/CSS; ?v=BUILD_ID busts cache per deploy)
→ route (27 routers under /api/*)
→ route (feature routers under /api/*)
→ authMiddleware (on protected routes: JWT, DB session check, 24h idle, last_activity update)
→ handler
→ response
@ -121,6 +125,12 @@ per-feature HTML fragments under `public/components/` fetched on demand. JS
modules talk via `window` globals and `CustomEvent` on `document` — no
bundler, no framework. Loader order is fixed in `index.html`.
Post-note helpers such as billing suggestions, don't-miss review, and patient
education handouts are reusable browser-side actions backed by authenticated
JSON APIs. The patient education helper generates a parent-facing plain-text
draft from the edited note and keeps the clinician in the review loop before
copying or sharing.
`authFetch.js` installs a global `fetch` interceptor that treats any 401 on an
authenticated request as a signal to clear local session state and redirect to
login. A `BroadcastChannel('pedscribe-auth')` pushes that signal to sibling
@ -157,4 +167,4 @@ The clinical assistant can call an external MCP-backed retrieval service. Ped-AI
## Speech
Browser Whisper and browser-local Whisper model downloads are removed from runtime. Speech-to-text routes through configured server-side providers. Browser-native Web Speech remains available only when explicitly enabled by user settings and browser support.
Browser Whisper and browser-local Whisper model downloads are removed from runtime. Speech-to-text routes through LiteLLM; upstream provider choice belongs in LiteLLM config. Browser-native Web Speech remains available only when explicitly enabled by user settings and browser support.

View file

@ -123,9 +123,23 @@ necessary UX tradeoff over perfect indistinguishability.
## Turnstile (Cloudflare bot protection)
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
@ -146,7 +160,7 @@ Helmet defaults plus:
- `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`
- Content-Security-Policy:
- `script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' cdn.jsdelivr.net cdnjs.cloudflare.com challenges.cloudflare.com`
(`unsafe-eval` is required by @xenova/transformers for in-browser Whisper)
(do not add `unsafe-eval` unless a reviewed dependency requires it)
- `script-src-attr 'none'` (blocks inline event handlers)
- `frame-src 'self' challenges.cloudflare.com`
- `object-src 'none'`

View file

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

View file

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

View file

@ -146,11 +146,11 @@ Container marked unhealthy after 5 failures.
## CI / CD
Four workflows fire on tag push:
On push (and tag push), these workflows run (depending on runner/site):
| Workflow | Output | Runtime |
|---|---|---|
| `android-release.yml` | Signed APK attached to the GitHub release | ~8 min |
| `.forgejo/workflows/android-apk.yml` | Signed APK attached to the Forgejo release, plus optional Google Play internal track upload | ~8 min |
| `docker-publish.yml` | Multi-arch image (amd64 + arm64 via native runners) on Docker Hub | ~4 min |
| `build-apk.yml` | Legacy TWA APK (optional second artifact) | ~2 min |

View file

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

View file

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

View file

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

View file

@ -8,9 +8,15 @@ Ped-AI generates pediatric clinical notes from typed input, dictation, or record
Model selection is available per task where the UI exposes a tab-level selector. Admin defaults provide the baseline model and user/task choices can override that baseline.
Generated notes can expose post-note helper panels. Billing suggestions and don't-miss review are clinician-facing. Patient education handouts are parent-facing drafts generated from the edited note, with optional diagnosis, medication, and preferred-language context. The clinician must verify the handout before sharing it.
## Phone Extensions And Pagers
The bedside tools include a per-user phone extension and pager directory. Entries support active/trash views, search, soft delete/restore, permanent purge, ZIP export, and JSON/ZIP import. Import preview flags exact active duplicates, exact trashed matches that can be restored, and possible duplicates before committing changes.
## Speech
Final transcription is server-side. Configure Google/Gemini, AWS Transcribe, LiteLLM, or OpenAI Whisper according to your deployment requirements.
Final transcription is server-side through LiteLLM. Configure upstream STT providers in LiteLLM rather than in Ped-AI.
Browser-native Web Speech is only an explicit opt-in preview path. It is not the final clinical transcript and may use browser-vendor cloud services.
@ -18,7 +24,7 @@ Browser Whisper and browser-local model workers are removed. Do not expect a pre
## Text To Speech
The voice preview button calls the configured TTS provider and plays the returned audio in the browser. If preview is silent, check that a voice is selected, a provider is configured, the user is authenticated, and browser autoplay has not blocked playback.
The voice preview button calls LiteLLM TTS and plays the returned audio in the browser. If preview is silent, check that a LiteLLM voice is selected, the gateway is configured, the user is authenticated, and browser autoplay has not blocked playback.
## Learning Hub
@ -61,6 +67,8 @@ Admins can manage users, roles, registration, security settings, model defaults,
| Browser Whisper | Removed | No public worker or model download path. |
| Learning Hub CMS | Active | Articles, pearls, quizzes, presentations. |
| Nextcloud WebDAV | Active | Used for file browsing/content import. |
| Patient handouts | Active | Parent-facing, note-derived, preferred-language draft. |
| Extension transfer | Active | ZIP export plus JSON/ZIP import preview. |
| Audio backups | Active | Failure recovery only. |
| TTS preview | Active | Depends on configured provider. |

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

@ -26,8 +26,12 @@ npx cap open android
## CI build (preferred)
Tag-triggered. Push any `vX.Y.Z` tag → `.github/workflows/android-release.yml`
builds a signed APK on a GitHub runner and attaches it to the matching release.
Push-triggered. Any push to `main`/feature branches and any `vX.Y.Z` tag push
`.forgejo/workflows/android-apk.yml` builds a signed APK on the Forgejo
runner.
Tagged builds additionally publish the artifact to the matching Forgejo release
as `pedscribe-<tag>.apk` so Obtainium can track updates.
Required repo secrets (set once, via Settings → Secrets and variables → Actions
or `gh secret set`):
@ -36,6 +40,14 @@ or `gh secret set`):
- `ANDROID_KEYSTORE_PASSWORD`
- `ANDROID_KEY_ALIAS``pedscribe`
- `ANDROID_KEY_PASSWORD`
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64` — base64 of your Google Play service
account JSON (optional). If present, the same tag build also runs `bundleRelease`
and uploads the AAB to Play's `internal` track.
Optional Play Store flow:
- Service account must have permissions to edit releases on the app in Play.
- Build task is `bundleRelease`, tracked as `com.pedshub.scribe`.
- Upload lane is `fastlane/android publish_internal` (under `mobile/android/fastlane`).
Tag a release:
@ -45,12 +57,13 @@ git commit -m "feat: ..." && git push # auto-version workflow bumps minor
git commit -m "fix: ..." && git push # auto-version workflow bumps patch
# or force an exact version
scripts/release.sh 6.2.0 --push
scripts/release.sh X.Y.Z --push
```
APK lands at the GitHub release; `/releases/latest` link in the login page
resolves to it automatically. Obtanium subscribers (`github.com/<owner>/<repo>`)
pick up the update on next poll.
APK lands on the Forgejo release. Obtainium can still track
`git.danvics.com/danvics/pediatric-ai-scribe-v3` releases automatically.
Play Store upload is handled automatically for tagged builds only when
`GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64` is configured.
## Local build (fallback / debugging)
@ -116,4 +129,5 @@ user to uninstall + reinstall.
| `mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java` | JS bridge + WebView mic permission |
| `mobile/android/app/src/main/java/com/pedshub/scribe/AudioRecordingService.java` | foreground service for background recording |
| `mobile/android/app/src/main/AndroidManifest.xml` | permissions, intents, backup rules |
| `.github/workflows/android-release.yml` | CI build |
| `.forgejo/workflows/android-apk.yml` | CI build |
| `mobile/android/fastlane/Fastfile` | internal Play track upload lane |

View file

@ -2,16 +2,13 @@
## Transcription
`POST /api/transcribe` accepts `multipart/form-data` with one audio file up to 25 MB. The provider is selected by `TRANSCRIBE_PROVIDER`, or auto-detected from available credentials.
`POST /api/transcribe` accepts `multipart/form-data` with one audio file up to 25 MB. Server STT is routed through LiteLLM.
Provider priority in auto mode is Google/Gemini, AWS Transcribe, LiteLLM, then OpenAI Whisper when configured.
Set `TRANSCRIBE_PROVIDER=litellm`, `LITELLM_API_BASE`, and `LITELLM_STT_MODEL`. Auto mode also uses LiteLLM when the gateway is configured.
| Provider | Notes | HIPAA posture |
|---|---|---|
| Google/Gemini | Uses the configured Vertex/Gemini STT model. | Eligible with the correct Google Cloud agreement. |
| AWS Transcribe | Supports standard and Medical mode. | Eligible with the correct AWS agreement. |
| LiteLLM | Sends audio through the configured LiteLLM `/audio/transcriptions` backend. | Depends on the selected upstream. |
| OpenAI Whisper | Uses `whisper-1` directly when `OPENAI_API_KEY` is configured. | Not HIPAA eligible unless your own agreement says otherwise. |
Browser Whisper and browser-local Whisper workers are not part of the runtime. Do not add browser model downloads or Transformers.js STT back into the public app.
@ -21,13 +18,13 @@ Browser-native Web Speech can show interim text when the user explicitly enables
## Text To Speech
`POST /api/text-to-speech` returns `audio/mpeg`. The `X-TTS-Provider` response header identifies the provider used. Requests are limited to 5000 characters.
`POST /api/text-to-speech` returns audio from LiteLLM `/audio/speech`. The `X-TTS-Provider` response header identifies the LiteLLM model used. Requests are limited to 5000 characters.
| Provider | Notes |
|---|---|
| Google Cloud TTS | Uses Google Cloud voices when configured. |
| LiteLLM | Uses `LITELLM_TTS_MODEL` and `LITELLM_TTS_VOICE`. |
| ElevenLabs | Available when configured; not HIPAA eligible by default. |
The admin/user voice pickers read available LiteLLM-compatible voices from `LITELLM_TTS_VOICES`.
## Audio Backup

View file

@ -1,44 +1,25 @@
# Transcription Options
Ped-AI currently supports server-side transcription plus an explicit browser Web Speech preview option. Browser Whisper was removed and should not be offered in settings, documentation, public workers, or model download scripts.
Ped-AI currently supports server-side transcription through LiteLLM plus an explicit browser Web Speech preview option. Browser Whisper was removed and should not be offered in settings, documentation, public workers, or model download scripts.
## Recommended Clinical Setup
Use a server-side provider covered by your compliance requirements.
Route STT through LiteLLM and configure the compliant upstream in LiteLLM.
| Need | Recommended provider |
|---|---|
| HIPAA-eligible cloud STT | Google/Gemini through Vertex AI or AWS Transcribe with a BAA. |
| OpenAI-compatible routing | LiteLLM with a compliant upstream. |
| Direct OpenAI Whisper | Only when acceptable for your deployment. |
| Server STT | LiteLLM with a compliant upstream. |
| Real-time draft preview | Browser Web Speech only with explicit user opt-in and privacy warning. |
Auto-detect uses LiteLLM when `LITELLM_API_BASE` is configured. Direct Google, AWS, local Whisper, and OpenAI Whisper branches are not part of the app runtime.
## Configuration
```env
TRANSCRIBE_PROVIDER=litellm
LITELLM_API_BASE=https://your-litellm.example/v1
LITELLM_API_KEY=<key>
LITELLM_STT_MODEL=whisper-1
```
Other provider examples:
```env
# Google/Gemini
TRANSCRIBE_PROVIDER=google
GOOGLE_VERTEX_PROJECT=your-project-id
GOOGLE_STT_MODEL=gemini-2.0-flash
# AWS Transcribe
TRANSCRIBE_PROVIDER=aws
AWS_BEDROCK_REGION=us-east-1
AWS_TRANSCRIBE_MEDICAL=true
AWS_TRANSCRIBE_SPECIALTY=PRIMARYCARE
# Direct OpenAI Whisper
TRANSCRIBE_PROVIDER=openai
OPENAI_API_KEY=<key>
LITELLM_STT_MODEL=local-parakeet-v3
```
## Failure Handling

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

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

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 713000
versionName "7.13.0"
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

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

View file

@ -13,7 +13,7 @@
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<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>
@ -22,4 +22,4 @@
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
</resources>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "pediatric-ai-scribe",
"version": "7.10.1",
"version": "7.14.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pediatric-ai-scribe",
"version": "7.10.1",
"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.13.0",
"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>
@ -325,6 +336,11 @@
<button id="btn-test-assistant-image-model" class="btn-sm btn-primary" type="button"><i class="fas fa-image"></i> Test</button>
</div>
<div id="assistant-image-test-result" style="font-size:12px;color:var(--g500);"></div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:-6px;">
<label style="font-size:12px;font-weight:600;color:var(--g600);min-width:130px;">Custom image model</label>
<input id="assistant-custom-image-model" type="text" placeholder="e.g. openrouter-gpt-5-image" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;">
<button id="btn-use-custom-assistant-image-model" class="btn-sm btn-ghost" type="button"><i class="fas fa-plus"></i> Use Custom</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Search result limit</label>

View file

@ -41,6 +41,7 @@
<textarea id="assistant-input" rows="3" placeholder="Ask a focused clinical question..." autocomplete="off"></textarea>
<div class="assistant-composer-footer">
<label class="assistant-check"><input type="checkbox" id="assistant-include-context" checked> retrieve broader context</label>
<button id="btn-assistant-cancel" class="btn-sm btn-ghost" type="button" hidden><i class="fas fa-stop"></i> Cancel search</button>
<button id="btn-assistant-send" class="btn-generate" type="submit"><i class="fas fa-paper-plane"></i> Ask</button>
</div>
</form>
@ -90,10 +91,11 @@
.assistant-status.busy .assistant-dot { background:var(--amber); animation:pulse 1.5s infinite; }
.assistant-status.error .assistant-dot { background:var(--red); }
.assistant-layout { display:grid; grid-template-columns:minmax(0,1fr) 330px; gap:14px; align-items:start; }
.assistant-main { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 190px); }
.assistant-layout > * { min-width:0; }
.assistant-main { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 190px); min-width:0; }
.assistant-toolbar { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--g200); background:var(--g50); }
.assistant-toolbar-actions { display:flex; gap:6px; flex-wrap:wrap; }
.assistant-messages { padding:16px; overflow-y:auto; background:linear-gradient(180deg,#fff,var(--g50)); }
.assistant-messages { padding:16px; overflow-y:auto; overflow-x:hidden; background:linear-gradient(180deg,#fff,var(--g50)); min-width:0; }
.assistant-empty { max-width:680px; margin:50px auto; text-align:center; color:var(--g500); }
.assistant-empty i { font-size:34px; color:var(--purple); margin-bottom:10px; }
.assistant-empty h3 { color:var(--g800); font-size:18px; margin-bottom:6px; }
@ -102,10 +104,10 @@
.assistant-suggestion-buttons { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
.assistant-suggestion-buttons button { border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; text-align:left; }
.assistant-suggestion-buttons button:hover { border-color:var(--purple); background:var(--purple-light); }
.assistant-msg { max-width:900px; margin:0 0 14px; display:grid; gap:6px; }
.assistant-msg { max-width:900px; min-width:0; margin:0 0 14px; display:grid; gap:6px; }
.assistant-msg.user { margin-left:auto; max-width:760px; }
.assistant-msg-label { font-size:11px; font-weight:700; color:var(--g400); text-transform:uppercase; letter-spacing:.04em; }
.assistant-bubble { border:1px solid var(--g200); border-radius:14px; padding:12px 14px; background:white; box-shadow:var(--shadow); font-size:13px; line-height:1.75; }
.assistant-bubble { border:1px solid var(--g200); border-radius:14px; padding:12px 14px; background:white; box-shadow:var(--shadow); font-size:13px; line-height:1.75; overflow-wrap:anywhere; min-width:0; max-width:100%; }
.assistant-msg.user .assistant-bubble { background:var(--blue); color:white; border-color:var(--blue); }
.assistant-bubble h1, .assistant-bubble h2, .assistant-bubble h3 { margin:16px 0 8px; line-height:1.25; color:var(--g900); }
.assistant-bubble h1:first-child, .assistant-bubble h2:first-child, .assistant-bubble h3:first-child { margin-top:0; }
@ -117,8 +119,11 @@
.assistant-bubble ul, .assistant-bubble ol { padding-left:20px; margin:8px 0; }
.assistant-bubble li { margin:4px 0; }
.assistant-bubble blockquote { margin:10px 0; padding:8px 12px; border-left:3px solid var(--blue); background:var(--blue-light); color:var(--g700); border-radius:8px; }
.assistant-bubble table { width:100%; border-collapse:separate; border-spacing:0; margin:12px 0; overflow:hidden; border:1px solid var(--g200); border-radius:10px; font-size:12px; }
.assistant-table-scroll { max-width:100%; overflow-x:auto; overflow-y:hidden; -webkit-overflow-scrolling:touch; margin:12px 0; border:1px solid var(--g200); border-radius:12px; background:white; box-shadow:inset 0 -1px 0 rgba(0,0,0,.03); }
.assistant-table-scroll table { width:max-content; min-width:100%; max-width:none; border-collapse:separate; border-spacing:0; margin:0; border:0; border-radius:0; font-size:12px; }
.assistant-table-scroll::after { content:'Swipe table'; display:none; position:sticky; left:0; bottom:0; padding:3px 9px; font-size:10px; font-weight:700; color:var(--g500); background:linear-gradient(90deg,rgba(255,255,255,.95),rgba(255,255,255,0)); pointer-events:none; }
.assistant-bubble th, .assistant-bubble td { padding:8px 10px; border-bottom:1px solid var(--g200); vertical-align:top; text-align:left; }
.assistant-bubble th, .assistant-bubble td { overflow-wrap:normal; word-break:normal; min-width:120px; }
.assistant-bubble th { background:var(--g50); font-weight:700; color:var(--g800); }
.assistant-bubble tr:last-child td { border-bottom:0; }
.assistant-bubble code { background:var(--g100); border-radius:4px; padding:1px 4px; }
@ -139,6 +144,8 @@
.assistant-composer textarea:focus, .assistant-side textarea:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); }
.assistant-composer-footer { display:flex; justify-content:space-between; align-items:center; gap:10px; }
.assistant-composer-footer .btn-generate { width:auto; margin:0; padding:9px 18px; }
.assistant-composer-footer #btn-assistant-cancel[hidden] { display:none !important; }
.assistant-composer-footer #btn-assistant-cancel:not([hidden]) { display:inline-flex; }
.assistant-check { font-size:12px; color:var(--g500); display:flex; align-items:center; gap:6px; }
.assistant-side { display:grid; gap:12px; }
.assistant-side-body { padding:12px; display:grid; gap:10px; font-size:13px; }
@ -148,10 +155,12 @@
.assistant-generated-image { display:grid; gap:8px; }
.assistant-generated-image img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
.assistant-image-actions { display:flex; gap:8px; flex-wrap:wrap; }
.assistant-image-preview-open { overflow:hidden; }
.assistant-image-modal { position:fixed; inset:0; z-index:9999; background:rgba(15,23,42,.82); display:flex; align-items:center; justify-content:center; padding:24px; }
.assistant-image-modal-card { position:relative; max-width:min(96vw,1200px); max-height:92vh; }
.assistant-image-modal-card { position:relative; display:grid; gap:10px; max-width:min(96vw,1200px); max-height:92vh; }
.assistant-image-modal-card img { max-width:100%; max-height:92vh; border-radius:14px; background:white; box-shadow:0 24px 80px rgba(0,0,0,.35); }
.assistant-image-modal-close { position:absolute; top:-12px; right:-12px; width:34px; height:34px; border:0; border-radius:999px; background:white; color:var(--g800); font-size:22px; line-height:1; cursor:pointer; box-shadow:var(--shadow); }
.assistant-image-modal-close { position:absolute; top:8px; right:8px; z-index:1; width:38px; height:38px; border:0; border-radius:999px; background:white; color:var(--g800); font-size:24px; line-height:1; cursor:pointer; box-shadow:var(--shadow); }
.assistant-image-modal-cancel { justify-self:center; border:0; border-radius:999px; background:white; color:var(--g800); font-weight:700; padding:9px 14px; box-shadow:var(--shadow); cursor:pointer; }
.assistant-sources { padding:10px 12px; display:grid; gap:8px; max-height:520px; overflow-y:auto; }
.assistant-saved-chats { padding:10px 12px; display:grid; gap:8px; max-height:220px; overflow-y:auto; }
.assistant-saved-chat { border:1px solid var(--g200); border-radius:10px; padding:8px; background:white; display:grid; gap:5px; }
@ -179,4 +188,20 @@
.assistant-muted { color:var(--g500); font-size:12px; line-height:1.6; }
.assistant-mermaid { background:white; border:1px solid var(--g200); border-radius:10px; padding:10px; margin:10px 0; overflow:auto; }
@media (max-width: 960px) { .assistant-layout { grid-template-columns:1fr; } .assistant-main { min-height:auto; grid-template-rows:auto minmax(320px,1fr) auto; } }
@media (max-width: 640px) {
.assistant-header { flex-direction:column; align-items:stretch; }
.assistant-status { align-self:flex-start; }
.assistant-toolbar { flex-direction:column; align-items:stretch; }
.assistant-toolbar-actions { display:grid; grid-template-columns:1fr 1fr; }
.assistant-toolbar-actions .btn-sm { width:100%; justify-content:center; }
.assistant-messages { padding:10px; }
.assistant-msg, .assistant-msg.user { max-width:100%; }
.assistant-bubble { font-size:13px; padding:11px 12px; }
.assistant-table-scroll::after { display:block; }
.assistant-composer { position:sticky; bottom:0; z-index:3; }
.assistant-composer-footer { flex-direction:column; align-items:stretch; }
.assistant-composer-footer .btn-generate { width:100%; }
.assistant-composer-footer #btn-assistant-cancel:not([hidden]) { width:100%; justify-content:center; }
.assistant-side { gap:10px; }
}
</style>

View file

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

View file

@ -166,6 +166,7 @@
<option value="template_ed">ED Template</option>
</select>
<input type="text" id="mem-name" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:150px;" placeholder="Template name (e.g. Normal PE)">
<a href="/template-guide.md" download="ped-ai-template-guide.md" class="btn-sm btn-ghost" style="text-decoration:none;"><i class="fas fa-download"></i> Template Guide</a>
</div>
<textarea id="mem-content" rows="5" style="width:100%;font-size:12px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Paste your template here. Example: HEENT: Normocephalic, atraumatic. Eyes: PERRL. Ears: TMs clear. Throat: clear..."></textarea>
<div style="margin-top:8px;display:flex;gap:8px;">

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

@ -143,6 +143,7 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
.btn-generate-green{background:var(--green);box-shadow:0 3px 10px rgba(16,185,129,0.25);}
.btn-sm{display:inline-flex;align-items:center;gap:4px;padding:5px 10px;border:none;border-radius:6px;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;transition:all 0.15s;}
#btn-assistant-cancel[hidden]{display:none!important;}
.btn-lg{display:inline-flex;align-items:center;gap:6px;padding:9px 18px;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s;}
.btn-primary{background:var(--blue);color:white;}.btn-primary:hover{background:var(--blue-dark);}
.btn-ghost{background:var(--g200);color:var(--g700);}.btn-ghost:hover{background:var(--g300);}
@ -396,6 +397,21 @@ textarea.full-input{resize:vertical;}
.wv-section-title{font-size:14px;font-weight:700;color:var(--g700);margin-bottom:12px;display:flex;align-items:center;gap:8px;}
.wv-section-title 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;}
@ -626,7 +642,7 @@ textarea.full-input{resize:vertical;}
.lh-quiz-q-type{font-size:11px;color:var(--g400);background:var(--g100);padding:2px 8px;border-radius:4px;}
.lh-quiz-q-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;}
@ -1139,6 +1155,8 @@ textarea.full-input{resize:vertical;}
.docs-reader-body th{background:var(--g50);font-weight:600;}
.docs-reader-body a{color:var(--blue);text-decoration:none;}
.docs-reader-body a:hover{text-decoration:underline;}
.docs-reader-body .docs-anchor-link{color:var(--blue);cursor:pointer;text-decoration:none;}
.docs-reader-body .docs-anchor-link:hover{text-decoration:underline;}
.docs-reader-body hr{border:0;border-top:1px solid var(--g200);margin:1.6em 0;}
@media (max-width:900px){

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

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

View file

@ -4,7 +4,7 @@
function adminEscapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function adminTableMessage(colspan, color, text) {
@ -701,8 +701,11 @@ 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();
});
function loadAssistantAdmin() {
@ -740,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);
@ -809,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');
@ -818,7 +907,7 @@ function adminFlashButtonBackground(btn, color) {
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Image test failed');
var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
if (result) result.innerHTML = 'Image model OK (' + data.duration + ' ms)' + (src ? '<div style="margin-top:8px;"><img src="' + src + '" alt="test image" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : '');
if (result) result.innerHTML = 'Image model OK (' + data.duration + ' ms)' + (src ? '<div style="margin-top:8px;"><img src="' + escAssistant(src) + '" alt="test image" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : '');
showToast('Image model works', 'success');
}).catch(function(err) {
if (result) result.textContent = err.message;
@ -826,6 +915,22 @@ function adminFlashButtonBackground(btn, color) {
});
}
function useCustomAssistantImageModel() {
var input = document.getElementById('assistant-custom-image-model');
var sel = document.getElementById('assistant-image-model');
var model = input ? input.value.trim() : '';
if (!model) { showToast('Enter an image model ID', 'error'); return; }
if (sel && !Array.prototype.some.call(sel.options, function(o) { return o.value === model; })) {
var opt = document.createElement('option');
opt.value = model;
opt.textContent = model + ' (custom)';
sel.appendChild(opt);
}
if (sel) sel.value = model;
window._assistantImageModelValue = model;
showToast('Custom image model selected. Save settings to keep it.', 'info');
}
function saveAssistantAdmin() {
var status = document.getElementById('assistant-admin-status');
if (status) status.textContent = 'Saving...';
@ -1275,12 +1380,12 @@ function adminFlashButtonBackground(btn, color) {
}
var items = data.voices || [];
if (items.length === 0) {
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No voices found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No voices/models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
return;
}
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' voices (provider: ' + esc(data.provider) + ')</p>' +
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' voices/models (provider: ' + esc(data.provider) + ')</p>' +
items.slice(0, 100).map(function(v) {
var isModel = (v.source || '').indexOf('gateway') !== -1 || (v.source || '').indexOf('builtin-model') !== -1;
var isModel = v.kind === 'model' || (v.source || '').indexOf('gateway') !== -1 || (v.source || '').indexOf('builtin-model') !== -1 || (v.source || '').indexOf('configured-model') !== -1;
var setType = isModel ? 'model' : 'voice';
var badge = isModel ? '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--blue);color:white;margin-left:4px;">MODEL</span>' : '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--green);color:white;margin-left:4px;">VOICE</span>';
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +

View file

@ -26,7 +26,15 @@ window.addEventListener('unhandledrejection', function(e) {
document.addEventListener('DOMContentLoaded', function() {
// --- COMPONENT LOADER (lazy-load tab HTML from /components/) ---
var COMPONENT_VERSION = '7.1.3';
function getComponentVersion() {
try {
var script = document.currentScript || document.querySelector('script[src^="/js/app.js"]');
var version = script ? new URL(script.src, window.location.href).searchParams.get('v') : '';
return version || 'dev';
} catch(e) { return 'dev'; }
}
var COMPONENT_VERSION = getComponentVersion();
window.PEDSCRIBE_COMPONENT_VERSION = COMPONENT_VERSION;
var _componentCache = {};
var _componentLoading = {};
@ -592,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();
@ -735,8 +753,8 @@ function suggestBillingCodes(outputElementId, noteText, noteType, patientAge, vi
if (data.emLevel) {
html += '<div class="billing-codes-section"><div class="billing-codes-label">E/M Assessment</div>';
html += '<span class="billing-code-chip em">Level ' + data.emLevel.level + '</span>';
html += '<span style="font-size:11px;color:var(--g500);margin-left:6px;">MDM: ' + data.emLevel.complexity + ' | ' + data.emLevel.diagnosisCount + ' dx | ' + data.emLevel.rosCount + ' ROS | ' + data.emLevel.peCount + ' PE</span>';
html += '<span class="billing-code-chip em">Level ' + escHtml(data.emLevel.level) + '</span>';
html += '<span style="font-size:11px;color:var(--g500);margin-left:6px;">MDM: ' + escHtml(data.emLevel.complexity) + ' | ' + escHtml(data.emLevel.diagnosisCount) + ' dx | ' + escHtml(data.emLevel.rosCount) + ' ROS | ' + escHtml(data.emLevel.peCount) + ' PE</span>';
html += '</div>';
}
@ -817,6 +835,112 @@ function suggestDontMiss(outputElementId, noteText, noteType, patientAge, chiefC
});
}
// ── Patient education handout helper ────────────────────────
// Adds a reusable "Handout" action beside generated clinical notes. The actual
// handout is generated only when the physician clicks Generate in the panel.
function attachPatientEducation(outputElementId, opts) {
var outputEl = document.getElementById(outputElementId);
if (!outputEl) return;
opts = opts || {};
var card = outputEl.closest('.card, .output-card');
if (!card) return;
var actions = card.querySelector('.output-actions');
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
if (actions && !document.getElementById(prefix + '-patient-ed-btn')) {
var btn = document.createElement('button');
btn.id = prefix + '-patient-ed-btn';
btn.className = 'btn-sm btn-ghost';
btn.type = 'button';
btn.innerHTML = '<i class="fas fa-person-breastfeeding"></i> Handout';
btn.addEventListener('click', function() {
var panel = ensurePatientEducationPanel(outputElementId, opts);
panel.classList.remove('hidden');
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
actions.appendChild(btn);
}
}
function ensurePatientEducationPanel(outputElementId, opts) {
var outputEl = document.getElementById(outputElementId);
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
var panelId = prefix + '-patient-ed';
var panel = document.getElementById(panelId);
if (panel) return panel;
panel = document.createElement('div');
panel.id = panelId;
panel.className = 'card patient-ed-card hidden';
panel.style.cssText = 'margin-top:10px;border-left:3px solid #0ea5e9;';
panel.innerHTML =
'<div class="card-header output-header">' +
'<h3><i class="fas fa-person-breastfeeding" style="color:#0ea5e9;"></i> Patient Education Handout</h3>' +
'<div class="output-actions">' +
'<button class="btn-sm btn-primary" id="' + prefix + '-patient-ed-generate" type="button"><i class="fas fa-wand-magic-sparkles"></i> Generate</button>' +
'</div>' +
'</div>' +
'<div style="padding:10px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;align-items:end;">' +
'<div class="demo-field"><label>Parent language</label><select id="' + prefix + '-patient-ed-language">' +
'<option>English</option><option>Spanish</option><option>French</option><option>Arabic</option><option>Haitian Creole</option><option>Chinese</option><option>Russian</option><option>Portuguese</option>' +
'</select></div>' +
'<div class="demo-field"><label>Diagnosis/context</label><input type="text" id="' + prefix + '-patient-ed-diagnosis" placeholder="Optional: diagnosis to emphasize"></div>' +
'<div class="demo-field"><label>Medications</label><input type="text" id="' + prefix + '-patient-ed-meds" placeholder="Optional: meds/doses from plan"></div>' +
'</div>' +
'<div id="' + prefix + '-patient-ed-text" class="output-text" contenteditable="true" style="margin:0 16px 12px;min-height:120px;" data-placeholder="Generated parent handout appears here..."></div>' +
'<div style="padding:0 16px 12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
'<button class="btn-sm btn-primary" data-action="copy" data-target="' + prefix + '-patient-ed-text"><i class="fas fa-copy"></i> Copy</button>' +
'<span style="font-size:11px;color:var(--g500);">Parent-facing draft. Verify before sharing.</span>' +
'</div>';
outputEl.parentNode.insertBefore(panel, outputEl.nextSibling);
var gen = panel.querySelector('#' + prefix + '-patient-ed-generate');
if (gen) gen.addEventListener('click', function() { generatePatientEducation(outputElementId, opts); });
return panel;
}
function generatePatientEducation(outputElementId, opts) {
var outputEl = document.getElementById(outputElementId);
if (!outputEl) return;
opts = opts || {};
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
var handoutEl = document.getElementById(prefix + '-patient-ed-text');
var langEl = document.getElementById(prefix + '-patient-ed-language');
var dxEl = document.getElementById(prefix + '-patient-ed-diagnosis');
var medsEl = document.getElementById(prefix + '-patient-ed-meds');
var noteText = (outputEl.innerText || outputEl.textContent || '').trim();
if (!noteText) { showToast('No note for handout', 'error'); return; }
if (handoutEl) handoutEl.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating parent handout...';
fetch('/api/patient-education', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
noteText: noteText,
diagnosis: dxEl ? dxEl.value : '',
medications: medsEl ? medsEl.value : '',
patientAge: opts.patientAge || '',
language: langEl ? langEl.value : 'English',
readingLevel: '6th grade plain language',
model: getSelectedModel()
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) {
if (handoutEl) handoutEl.textContent = '';
showToast(data.error || 'Handout generation failed', 'error');
return;
}
setOutputText(handoutEl, data.handout || '');
showToast('Patient handout generated', 'success');
})
.catch(function(err) {
if (handoutEl) handoutEl.textContent = '';
showToast(err.message || 'Handout generation failed', 'error');
});
}
function refineDocument(outputElementId, inputElementId) {
var doc = document.getElementById(outputElementId);
var input = document.getElementById(inputElementId);
@ -914,7 +1038,7 @@ function deduplicateFinal(newText, existingText) {
// PWA Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function() {});
navigator.serviceWorker.register('/sw.js?v=' + encodeURIComponent(window.PEDSCRIBE_COMPONENT_VERSION || 'dev')).catch(function() {});
}
console.log('✅ App.js loaded');

View file

@ -19,20 +19,24 @@ export function fetchAssistantExamples() {
.then(function(r) { return r.json(); });
}
export function openAssistantStream(payload) {
export function openAssistantStream(payload, options) {
options = options || {};
return fetch('/api/clinical-assistant/chat/stream', {
method: 'POST',
headers: authHeaders(),
credentials: 'same-origin',
signal: options.signal,
body: JSON.stringify(payload)
});
}
export function fetchAssistantChat(payload) {
export function fetchAssistantChat(payload, options) {
options = options || {};
return fetch('/api/clinical-assistant/chat', {
method: 'POST',
headers: authHeaders(),
credentials: 'same-origin',
signal: options.signal,
body: JSON.stringify(payload)
}).then(parseJsonWithStatus);
}
@ -46,6 +50,22 @@ export function requestAssistantImage(prompt) {
}).then(function(r) { return r.json(); });
}
export function startAssistantImageJob(prompt) {
return fetch('/api/clinical-assistant/image/jobs', {
method: 'POST',
headers: authHeaders(),
credentials: 'same-origin',
body: JSON.stringify({ prompt: prompt })
}).then(function(r) { return r.json(); });
}
export function fetchAssistantImageJob(jobId) {
return fetch('/api/clinical-assistant/image/jobs/' + encodeURIComponent(jobId), {
headers: authHeaders(),
credentials: 'same-origin'
}).then(function(r) { return r.json(); });
}
export function saveAssistantChat(payload) {
return fetch('/api/clinical-assistant/chats', {
method: 'POST',

View file

@ -1,12 +1,12 @@
export function renderAssistantMarkdown(md, sources, options) {
var opts = options || {};
var text = stripOrphanMarkdownMarkers(normalizeMarkdownText(md));
var codeBlocks = [];
text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) {
var text = String(md || '').replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) {
var idx = codeBlocks.length;
codeBlocks.push({ lang: (lang || '').toLowerCase(), code: code });
return '\n@@CODEBLOCK_' + idx + '@@\n';
});
text = stripOrphanMarkdownMarkers(normalizeMarkdownText(text));
text = renderLatexText(text, opts.katex);
text = normalizeAdjacentCitationClusters(text, sources || []);
@ -26,10 +26,17 @@ export function renderAssistantMarkdown(md, sources, options) {
if (block.lang === 'chart' || block.lang === 'chartjs') return '<canvas class="assistant-chart" data-chart="' + escapeAttr(block.code) + '"></canvas>';
return '<pre><code>' + escapeHtml(block.code) + '</code></pre>';
});
html = wrapTables(html);
return typeof opts.sanitize === 'function' ? opts.sanitize(html) : html;
}
function wrapTables(html) {
return String(html || '')
.replace(/<table(\s[^>]*)?>/g, '<div class="assistant-table-scroll"><table$1>')
.replace(/<\/table>/g, '</table></div>');
}
export function renderCitationLinks(html, sources, options) {
var opts = options || {};
return String(html || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function (match, cluster) {
@ -84,7 +91,7 @@ function formatCitationCluster(nums) {
}
export function normalizeMarkdownText(text) {
return stripOrphanMarkdownMarkers(String(text || '')
return stripOrphanMarkdownMarkers(normalizeTableSourceCitationCells(String(text || '')
.replace(/\r\n/g, '\n')
.replace(/(\[(?:\d+\s*,\s*)*\d+\])\s*[-–—]\s*/g, '$1\n- ')
.replace(/([.!?])\s*[-–—]\s+(\*\*)?/g, '$1\n- $2')
@ -97,7 +104,49 @@ export function normalizeMarkdownText(text) {
.replace(/(#{1,4}\s+[^\n]+)\n(-\s+)/g, '$1\n\n$2')
.replace(/([^\n])\s+(-\s+(?:Mainstay|Medications|Hospitalization|Other therapies|Prevention|Short-acting|Anticholinergics|Systemic|Adjuncts|Long-term|Infants|Differentiating|Persistent|Severe|Need for|Inadequate)\b)/g, '$1\n$2')
.replace(/([^\n])\s+(-\s+[^\n])/g, '$1\n$2')
.trim());
.trim()));
}
export function normalizeTableSourceCitationCells(text) {
var lines = String(text || '').split('\n');
for (var i = 0; i < lines.length - 1; i++) {
if (!/^\s*\|.*\|\s*$/.test(lines[i]) || !/^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(lines[i + 1])) continue;
var header = tableCells(lines[i]);
var sourceCols = [];
header.forEach(function(cell, idx) {
if (/^(?:source|sources|source\(s\)|citation|citations|citation\(s\)|reference|references|ref|refs)$/i.test(cell.trim())) sourceCols.push(idx);
});
if (!sourceCols.length) continue;
var j = i + 2;
while (j < lines.length && /^\s*\|.*\|\s*$/.test(lines[j])) {
lines[j] = rewriteTableCells(lines[j], sourceCols, function(cell) {
return normalizeBareCitationCell(cell);
});
j++;
}
i = j - 1;
}
return lines.join('\n');
}
function rewriteTableCells(line, indexes, fn) {
var trimmed = String(line || '').trim();
var leading = /^\|/.test(trimmed);
var trailing = /\|$/.test(trimmed);
var cells = tableCells(line);
indexes.forEach(function(idx) {
if (idx < cells.length) cells[idx] = fn(cells[idx]);
});
return (leading ? '| ' : '') + cells.join(' | ') + (trailing ? ' |' : '');
}
function normalizeBareCitationCell(cell) {
var text = String(cell || '').trim();
if (/^\[(?:\d+\s*,\s*)*\d+\]$/.test(text)) return text;
if (/^\d+(?:\s*,\s*\d+)*$/.test(text)) return '[' + text.replace(/\s*,\s*/g, ', ') + ']';
return text.replace(/(^|\s)(\d+(?:\s*,\s*\d+)+)(?=$|\s)/g, function(match, prefix, nums) {
return prefix + '[' + nums.replace(/\s*,\s*/g, ', ') + ']';
});
}
export function stripOrphanMarkdownMarkers(text) {

View file

@ -18,18 +18,26 @@ export function createAssistantExporter(options) {
}
var exportItems = collectExportItems(state.messages || [], state.lastAnswer, state.lastSources || []);
var cacheKey = buildExportCacheKey(exportItems, state.lastGeneratedImageSrc || '');
if (exportCacheKey === cacheKey && exportCacheItems) {
showPrintableExport(exportCacheItems, state.lastGeneratedImageSrc || '');
return;
}
exportCacheKey = cacheKey;
exportCacheItems = exportItems;
showPrintableExport(exportItems, state.lastGeneratedImageSrc || '');
}
function showPrintableExport(items, imageSrc) {
if (shouldUseInlineExport()) {
writeInlineChatExport(items, imageSrc);
return;
}
var doc = openExportWindow();
if (!doc) {
if (typeof options.showToast === 'function') options.showToast('Allow popups to export PDF', 'error');
return;
}
if (exportCacheKey === cacheKey && exportCacheItems) {
writePrintableChatExport(doc, exportCacheItems, state.lastGeneratedImageSrc || '');
return;
}
exportCacheKey = cacheKey;
exportCacheItems = exportItems;
writePrintableChatExport(doc, exportItems, state.lastGeneratedImageSrc || '');
writePrintableChatExport(doc, items, imageSrc);
}
function openExportWindow() {
@ -43,6 +51,62 @@ export function createAssistantExporter(options) {
function writePrintableChatExport(doc, items, imageSrc) {
if (!doc || doc.closed) return;
var html = buildPrintableChatHtml(items, imageSrc, false);
doc.document.open();
doc.document.write(html);
doc.document.close();
try {
var printBtn = doc.document.getElementById('assistant-export-print');
if (printBtn) printBtn.addEventListener('click', function () { doc.focus(); doc.print(); });
var closeBtn = doc.document.getElementById('assistant-export-close');
if (closeBtn) closeBtn.addEventListener('click', function () { try { doc.close(); } catch (e) { doc.location.href = '/'; } });
} catch (e) {}
setTimeout(function () { try { doc.focus(); doc.print(); } catch (e) {} }, 500);
}
function writeInlineChatExport(items, imageSrc) {
closeInlineExport();
var modal = document.createElement('div');
modal.id = 'assistant-export-modal';
modal.innerHTML = '<style>' + inlineExportCss() + exportTableScrollCss() + '</style><div class="assistant-export-sheet">' + buildPrintableChatBody(items, imageSrc, true) + '</div>';
document.body.appendChild(modal);
document.body.classList.add('assistant-export-open');
var printBtn = modal.querySelector('#assistant-export-print');
var closeBtn = modal.querySelector('#assistant-export-close');
if (printBtn) printBtn.addEventListener('click', async function () {
printBtn.disabled = true;
var originalText = printBtn.textContent;
printBtn.textContent = 'Preparing export...';
try {
var html = buildPrintableChatHtml(items, imageSrc, false);
if (await printWithNativeBridge(html)) return;
if (!(await saveInlineExport(html))) window.print();
} finally {
printBtn.disabled = false;
printBtn.textContent = originalText;
}
});
if (closeBtn) closeBtn.addEventListener('click', closeInlineExport);
modal.addEventListener('click', function (event) {
if (event.target === modal) closeInlineExport();
});
try { window.history.pushState({ assistantExport: true }, '', window.location.href); } catch (e) {}
window.addEventListener('popstate', closeInlineExport, { once: true });
}
function closeInlineExport() {
var existing = document.getElementById('assistant-export-modal');
if (existing) existing.remove();
document.body.classList.remove('assistant-export-open');
}
function buildPrintableChatHtml(items, imageSrc, inline) {
return '<!doctype html><html><head><title>Clinical Assistant Export</title>' +
'<style>' + exportWindowCss() + exportTableScrollCss() + '</style>' +
'</head><body>' + buildPrintableChatBody(items, imageSrc, inline) + '</body></html>';
}
function buildPrintableChatBody(items, imageSrc, inline) {
items = Array.isArray(items) && items.length ? items : [];
var imageHtml = imageSrc ? '<h2>Generated Image</h2><div class="export-image"><img src="' + escapeAttr(imageSrc) + '" alt="Generated clinical visual"></div>' : '';
var sections = items.map(function (item, idx) {
@ -60,20 +124,10 @@ export function createAssistantExporter(options) {
(refs ? '<h3>References</h3><ol class="refs">' + refs + '</ol>' : '') +
'</section>';
}).join('');
var html = '<!doctype html><html><head><title>Clinical Assistant Export</title>' +
'<style>body{font-family:Arial,sans-serif;color:#111827;line-height:1.55;margin:36px;max-width:820px}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin-top:26px;border-bottom:1px solid #e5e7eb;padding-bottom:4px;break-after:avoid}h3{font-size:14px;margin:18px 0 8px;break-after:avoid}.meta,.question{font-size:12px;color:#6b7280;margin-bottom:12px}.answer{font-size:13px}.export-section{margin-top:20px;break-before:auto}.full-answer{page-break-before:auto}.export-image img{max-width:100%;border:1px solid #e5e7eb;border-radius:10px}.refs{font-size:12px;padding-left:20px;margin-top:8px;break-inside:auto}.refs li{margin:6px 0;break-inside:avoid}.assistant-cite{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 5px;margin:0 1px;border-radius:999px;background:#f3e8ff;color:#7c3aed;border:1px solid rgba(124,58,237,.22);font-size:9px;font-weight:800;line-height:16px;text-decoration:none;text-transform:uppercase;letter-spacing:.03em;vertical-align:baseline;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer table{width:100%;border-collapse:collapse;table-layout:auto;margin:12px 0 18px;border:1px solid #e5e7eb;page-break-inside:auto}.answer th,.answer td{padding:7px 8px;border:1px solid #e5e7eb;text-align:left;vertical-align:top;word-break:normal;overflow-wrap:break-word}.answer th{background:#f9fafb;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer th:last-child,.answer td:last-child{width:1%;white-space:nowrap}.answer tr{break-inside:avoid;page-break-inside:avoid}.answer thead{display:table-header-group}.answer tbody{display:table-row-group}.answer p{margin:8px 0}.answer ul,.answer ol{padding-left:20px}.answer li{margin:4px 0}@media print{button{display:none}body{margin:24mm}.export-section{break-inside:auto}.refs{break-before:avoid}}</style>' +
'</head><body><button id="assistant-export-print" type="button" style="float:right;padding:8px 12px">Print / Save PDF</button><h1>Clinical Assistant Export</h1>' +
return '<div class="assistant-export-actions"><button id="assistant-export-print" type="button">Print / Save PDF</button><button id="assistant-export-close" type="button">Close</button></div><h1>Clinical Assistant Export</h1>' +
'<div class="meta">Export generated ' + escapeHtml(new Date().toLocaleString()) + '</div>' +
imageHtml +
sections + '</body></html>';
doc.document.open();
doc.document.write(html);
doc.document.close();
try {
var printBtn = doc.document.getElementById('assistant-export-print');
if (printBtn) printBtn.addEventListener('click', function () { doc.focus(); doc.print(); });
} catch (e) {}
setTimeout(function () { try { doc.focus(); doc.print(); } catch (e) {} }, 500);
sections;
}
function renderMarkdown(md, sources, renderOptions) {
@ -87,6 +141,70 @@ export function createAssistantExporter(options) {
};
}
function shouldUseInlineExport() {
var isCapacitor = !!(window.Capacitor && (!window.Capacitor.isNativePlatform || window.Capacitor.isNativePlatform()));
var isSmallTouch = window.matchMedia && window.matchMedia('(max-width: 700px), (pointer: coarse)').matches;
return isCapacitor || isSmallTouch;
}
async function saveInlineExport(html) {
var plugins = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins : null;
if (!plugins || !plugins.Filesystem) return false;
try {
var name = 'clinical-assistant-export-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.html';
var saved = await plugins.Filesystem.writeFile({ path: name, data: utf8ToBase64(html), directory: 'CACHE' });
if (plugins.Share && saved && saved.uri) {
try {
await plugins.Share.share({ title: 'Clinical Assistant Export', text: 'Open this export and use Print to save as PDF.', url: saved.uri, dialogTitle: 'Save or share export' });
} catch (e) {
if (!isShareCancel(e)) throw e;
}
}
if (typeof window.showToast === 'function') window.showToast('Export prepared', 'success');
return true;
} catch (e) {
if (typeof window.showToast === 'function') window.showToast(e.message || 'Export failed', 'error');
return false;
}
}
async function printWithNativeBridge(html) {
if (!window.NativePrint || typeof window.NativePrint.printHtml !== 'function') return false;
try {
window.NativePrint.printHtml('Clinical Assistant Export', utf8ToBase64(html));
return true;
} catch (e) {
if (typeof window.showToast === 'function') window.showToast(e.message || 'Native print failed', 'error');
return false;
}
}
function utf8ToBase64(value) {
var bytes = new TextEncoder().encode(String(value || ''));
var binary = '';
for (var i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
}
function isShareCancel(error) {
var message = String(error && (error.message || error.name) || '').toLowerCase();
return /cancel|abort|dismiss|user denied|share canceled/.test(message);
}
function exportWindowCss() {
return 'body{font-family:Arial,sans-serif;color:#111827;line-height:1.55;margin:36px;max-width:820px}.assistant-export-actions{display:flex;gap:8px;justify-content:flex-end;margin-bottom:12px}.assistant-export-actions button{padding:8px 12px}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin-top:26px;border-bottom:1px solid #e5e7eb;padding-bottom:4px;break-after:avoid}h3{font-size:14px;margin:18px 0 8px;break-after:avoid}.meta,.question{font-size:12px;color:#6b7280;margin-bottom:12px}.answer{font-size:13px}.export-section{margin-top:20px;break-before:auto}.full-answer{page-break-before:auto}.export-image img{max-width:100%;border:1px solid #e5e7eb;border-radius:10px}.refs{font-size:12px;padding-left:20px;margin-top:8px;break-inside:auto}.refs li{margin:6px 0;break-inside:avoid}.assistant-cite{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 5px;margin:0 1px;border-radius:999px;background:#f3e8ff;color:#7c3aed;border:1px solid rgba(124,58,237,.22);font-size:9px;font-weight:800;line-height:16px;text-decoration:none;text-transform:uppercase;letter-spacing:.03em;vertical-align:baseline;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer table{width:100%;border-collapse:collapse;table-layout:auto;margin:12px 0 18px;border:1px solid #e5e7eb;page-break-inside:auto}.answer th,.answer td{padding:7px 8px;border:1px solid #e5e7eb;text-align:left;vertical-align:top;word-break:normal;overflow-wrap:break-word}.answer th{background:#f9fafb;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer th:last-child,.answer td:last-child{width:1%;white-space:nowrap}.answer tr{break-inside:avoid;page-break-inside:avoid}.answer thead{display:table-header-group}.answer tbody{display:table-row-group}.answer p{margin:8px 0}.answer ul,.answer ol{padding-left:20px}.answer li{margin:4px 0}@media print{.assistant-export-actions{display:none}body{margin:24mm}.export-section{break-inside:auto}.refs{break-before:avoid}}';
}
function inlineExportCss() {
return '#assistant-export-modal{position:fixed;inset:0;z-index:10000;background:rgba(15,23,42,.72);overflow:auto;padding:16px}#assistant-export-modal .assistant-export-sheet{box-sizing:border-box;background:white;color:#111827;font-family:Arial,sans-serif;line-height:1.55;max-width:860px;margin:0 auto 24px;padding:24px;border-radius:14px;box-shadow:0 24px 80px rgba(0,0,0,.35)}#assistant-export-modal .assistant-export-actions{display:flex;gap:8px;justify-content:flex-end;margin-bottom:12px}#assistant-export-modal .assistant-export-actions button{padding:8px 12px}#assistant-export-modal h1{font-size:22px;margin:0 0 4px}#assistant-export-modal h2{font-size:17px;margin-top:26px;border-bottom:1px solid #e5e7eb;padding-bottom:4px;break-after:avoid}#assistant-export-modal h3{font-size:14px;margin:18px 0 8px;break-after:avoid}#assistant-export-modal .meta,#assistant-export-modal .question{font-size:12px;color:#6b7280;margin-bottom:12px}#assistant-export-modal .answer{font-size:13px}#assistant-export-modal .export-section{margin-top:20px;break-before:auto}#assistant-export-modal .full-answer{page-break-before:auto}#assistant-export-modal .export-image img{max-width:100%;border:1px solid #e5e7eb;border-radius:10px}#assistant-export-modal .refs{font-size:12px;padding-left:20px;margin-top:8px;break-inside:auto}#assistant-export-modal .refs li{margin:6px 0;break-inside:avoid}#assistant-export-modal .assistant-cite{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 5px;margin:0 1px;border-radius:999px;background:#f3e8ff;color:#7c3aed;border:1px solid rgba(124,58,237,.22);font-size:9px;font-weight:800;line-height:16px;text-decoration:none;text-transform:uppercase;letter-spacing:.03em;vertical-align:baseline;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact}#assistant-export-modal .answer table{width:100%;border-collapse:collapse;table-layout:auto;margin:12px 0 18px;border:1px solid #e5e7eb;page-break-inside:auto}#assistant-export-modal .answer th,#assistant-export-modal .answer td{padding:7px 8px;border:1px solid #e5e7eb;text-align:left;vertical-align:top;word-break:normal;overflow-wrap:break-word}#assistant-export-modal .answer th{background:#f9fafb;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}#assistant-export-modal .answer th:last-child,#assistant-export-modal .answer td:last-child{width:1%;white-space:nowrap}#assistant-export-modal .answer tr{break-inside:avoid;page-break-inside:avoid}#assistant-export-modal .answer thead{display:table-header-group}#assistant-export-modal .answer tbody{display:table-row-group}#assistant-export-modal .answer p{margin:8px 0}#assistant-export-modal .answer ul,#assistant-export-modal .answer ol{padding-left:20px}#assistant-export-modal .answer li{margin:4px 0}@media print{body.assistant-export-open>*:not(#assistant-export-modal){display:none!important}#assistant-export-modal{position:static!important;inset:auto!important;background:white!important;overflow:visible!important;padding:0!important}#assistant-export-modal .assistant-export-sheet{max-width:none!important;margin:0!important;padding:0!important;border-radius:0!important;box-shadow:none!important}#assistant-export-modal .assistant-export-actions{display:none!important}#assistant-export-modal .export-section{break-inside:auto}#assistant-export-modal .refs{break-before:avoid}}';
}
function exportTableScrollCss() {
return '.assistant-table-scroll{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;margin:12px 0 18px;border:1px solid #e5e7eb;border-radius:10px;background:white}.answer .assistant-table-scroll table,#assistant-export-modal .answer .assistant-table-scroll table{width:max-content;min-width:100%;max-width:none;margin:0;border:0;border-radius:0}.answer .assistant-table-scroll th,.answer .assistant-table-scroll td,#assistant-export-modal .answer .assistant-table-scroll th,#assistant-export-modal .answer .assistant-table-scroll td{min-width:120px;overflow-wrap:normal;word-break:normal}.assistant-table-scroll::after{content:"Swipe table";display:block;position:sticky;left:0;bottom:0;padding:3px 9px;font-size:10px;font-weight:700;color:#6b7280;background:linear-gradient(90deg,rgba(255,255,255,.95),rgba(255,255,255,0));pointer-events:none}@media print{.assistant-table-scroll{overflow:visible;border:0}.assistant-table-scroll::after{display:none}.answer .assistant-table-scroll table,#assistant-export-modal .answer .assistant-table-scroll table{width:100%;max-width:100%}}';
}
function collectExportItems(messages, lastAnswer, lastSources) {
var items = [];
var pendingQuestion = '';

View file

@ -3,29 +3,67 @@ import { escapeAttr } from './citations.js';
export function createAssistantImageStore() {
var generatedImages = {};
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>' +
'<a class="btn-sm btn-ghost" href="' + escapeAttr(src) + '" download="clinical-visual.png"><i class="fas fa-download"></i> Download</a>' +
'<button type="button" class="btn-sm btn-ghost" data-assistant-download-image="' + escapeAttr(id) + '"><i class="fas fa-download"></i> Download</button>' +
'</div></div>';
}
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');
modal.className = 'assistant-image-modal';
modal.innerHTML = '<div class="assistant-image-modal-card"><button type="button" class="assistant-image-modal-close" aria-label="Close">&times;</button><img src="' + escapeAttr(src) + '" alt="Generated clinical visual"></div>';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.innerHTML = '<div class="assistant-image-modal-card"><button type="button" class="assistant-image-modal-close" aria-label="Close">&times;</button><img src="' + escapeAttr(src) + '" alt="Generated clinical visual"><button type="button" class="assistant-image-modal-cancel">Close preview</button></div>';
modal.addEventListener('click', function (event) {
if (event.target === modal || event.target.closest('.assistant-image-modal-close') || event.target.closest('.assistant-image-modal-cancel')) closeImagePreview();
});
previewKeyHandler = function (event) { if (event.key === 'Escape') closeImagePreview(); };
document.addEventListener('keydown', previewKeyHandler);
document.body.appendChild(modal);
document.body.classList.add('assistant-image-preview-open');
}
function closeImagePreview() {
document.querySelectorAll('.assistant-image-modal').forEach(function (el) { el.remove(); });
document.body.classList.remove('assistant-image-preview-open');
if (previewKeyHandler) document.removeEventListener('keydown', previewKeyHandler);
previewKeyHandler = null;
}
async function downloadImage(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');
}
}
function clear() {
@ -36,11 +74,214 @@ export function createAssistantImageStore() {
return {
renderGeneratedImage: renderGeneratedImage,
openImagePreview: openImagePreview,
downloadImage: downloadImage,
closeImagePreview: closeImagePreview,
clear: clear
};
}
async function saveWithNativeShare(src) {
if (isNativeApp()) {
if (await saveWithNativeImageBridge(src)) return true;
return await saveWithCapacitorShare(src);
}
var webShare = await shareWithWebFile(src);
if (webShare !== 'unavailable') return true;
return await saveWithCapacitorShare(src);
}
async function saveWithNativeImageBridge(src) {
if (!window.NativeFiles || typeof window.NativeFiles.saveImage !== 'function') return false;
try {
var base64 = await imageSourceToBase64(src);
var name = 'clinical-visual-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.png';
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;
}
if (result.indexOf('error:') === 0 && typeof window.showToast === 'function') {
window.showToast(result.slice(6) || 'Native image save failed', 'error');
}
} catch (e) {
if (typeof window.showToast === 'function') window.showToast(e.message || 'Native image save failed', 'error');
}
return false;
}
async function saveWithCapacitorShare(src) {
var plugins = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins : null;
if (!plugins) return false;
if (!plugins.Filesystem && plugins.Share && !/^data:image\//i.test(src)) {
try {
await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: src, dialogTitle: 'Save or share clinical visual' });
return true;
} catch (e) { return isShareCancel(e); }
}
if (!plugins.Filesystem) return false;
try {
var base64 = await imageSourceToBase64(src);
var name = 'clinical-visual-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.png';
var saved = await plugins.Filesystem.writeFile({ path: name, data: base64, directory: 'CACHE' });
if (plugins.Share && saved && saved.uri) {
try {
await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: saved.uri, dialogTitle: 'Save or share clinical visual' });
} catch (e) {
if (!isShareCancel(e)) throw e;
}
}
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. Use Preview and long-press the image to save it.', 'error');
return false;
}
}
async function shareWithWebFile(src) {
if (!navigator.share || !navigator.canShare || typeof File === 'undefined') return 'unavailable';
try {
var blob = await imageSourceToBlob(src);
var file = new File([blob], 'clinical-visual.png', { type: blob.type || 'image/png' });
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) ? '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');
a.href = objectUrl;
a.download = 'clinical-visual.png';
a.rel = 'noopener';
document.body.appendChild(a);
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()) {
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() {
return !isNativeApp() && /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || '');
}
function isNativeApp() {
return !!(window.Capacitor && (!window.Capacitor.isNativePlatform || window.Capacitor.isNativePlatform()));
}
function isShareCancel(error) {
var message = String(error && (error.message || error.name) || '').toLowerCase();
return /cancel|abort|dismiss|user denied|share canceled/.test(message);
}
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] || ''); };
reader.onerror = function() { reject(reader.error || new Error('Could not read image')); };
reader.readAsDataURL(blob);
});
}
async function imageSourceToBlob(src) {
if (/^data:image\//i.test(src)) {
var parts = src.split(',');
var meta = parts[0] || 'data:image/png;base64';
var binary = atob(parts[1] || '');
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new Blob([bytes], { type: (meta.match(/data:([^;]+)/) || [])[1] || 'image/png' });
}
var response = await fetch(src, { credentials: 'omit' });
if (!response.ok) throw new Error('Image download failed');
return response.blob();
}
export function buildContextualImagePrompt(request, lastAnswer, lastSources) {
var prompt = String(request || '').trim();
if (!lastAnswer) return prompt;
@ -52,8 +293,9 @@ export function buildContextualImagePrompt(request, lastAnswer, lastSources) {
export function isImageRequest(text) {
text = String(text || '').trim();
var visualNoun = /\b(image|photo|picture|visual|illustration|diagram|figure|flowchart|infographic)\b/i;
return /^(image|photo|picture|visual|illustration|diagram|figure|flowchart|infographic)$/i.test(text) ||
/\b(show|see|display|view|image|photo|picture|visual|illustration|diagram|figure)\b/i.test(text) && text.split(/\s+/).length <= 6 ||
(/\b(show|see|display|view)\b/i.test(text) && visualNoun.test(text) && text.split(/\s+/).length <= 8) ||
/\b(generate|create|draw|make|render)\b[\s\S]{0,80}\b(image|picture|illustration|visual|infographic|diagram|flowchart)\b/i.test(text) ||
/\b(generate|create|draw|make|render)\b[\s\S]{0,80}\b(an?|the)?\s*(algorithm|pathway|poster|teaching visual)\b/i.test(text);
}

View file

@ -38,6 +38,7 @@ function renderSourceBadges(source) {
function cleanSourceExcerpt(text) {
return String(text || '')
.replace(/^\[Page-image match\]\s*/i, '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/\*\*/g, '')
.replace(/\|\s*-{2,}\s*/g, ' ')

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

@ -174,6 +174,7 @@
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('Chart review generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('cr-review-text', { patientAge: document.getElementById('cr-age').value });
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('cr-review-text', data.review, 'chart', document.getElementById('cr-age').value);
} else showToast(data.error || 'Failed', 'error');
})
@ -190,6 +191,7 @@
var outputCard = document.getElementById('cr-output');
if (reviewText) reviewText.textContent = enc.generated_note;
if (outputCard) outputCard.classList.remove('hidden');
if (typeof attachPatientEducation === 'function') attachPatientEducation('cr-review-text', { patientAge: document.getElementById('cr-age').value });
}
});

View file

@ -16,7 +16,8 @@ import {
fetchSavedAssistantChat,
fetchSavedAssistantChats,
openAssistantStream,
requestAssistantImage,
fetchAssistantImageJob,
startAssistantImageJob,
saveAssistantChat
} from './assistant/api.js';
var initialized = false;
@ -28,6 +29,8 @@ import {
var lastGeneratedImageSrc = '';
var markdownRenderer = null;
var assistantBusy = false;
var activeAssistantRequest = null;
var STREAM_MARKDOWN_LIMIT = 3500;
var exporter = createAssistantExporter({ renderMarkdown: renderMarkdown, showToast: window.showToast });
var imageStore = createAssistantImageStore();
@ -48,6 +51,7 @@ import {
function bindEvents() {
var form = document.getElementById('assistant-form');
var clearBtn = document.getElementById('btn-assistant-clear');
var cancelBtn = document.getElementById('btn-assistant-cancel');
var copyBtn = document.getElementById('btn-assistant-copy');
var saveBtn = document.getElementById('btn-assistant-save');
var saveConfirmBtn = document.getElementById('btn-assistant-save-confirm');
@ -59,6 +63,7 @@ import {
if (form) form.addEventListener('submit', onAsk);
if (clearBtn) clearBtn.addEventListener('click', clearConversation);
if (cancelBtn) cancelBtn.addEventListener('click', cancelAssistantSearch);
if (copyBtn) copyBtn.addEventListener('click', copyLastAnswer);
if (saveBtn) saveBtn.addEventListener('click', showSavePanel);
if (saveConfirmBtn) saveConfirmBtn.addEventListener('click', saveCurrentChat);
@ -125,21 +130,40 @@ import {
return;
}
var request = createAssistantRequest();
activeAssistantRequest = request;
setBusy(true, 'Looking up sources...');
var loading = appendLoadingMessage('Looking up sources', 'Retrieving and synthesizing references...');
request.loading = loading;
streamAssistantResponse({
message: text,
history: messages.slice(-8),
includeContext: !includeContext || includeContext.checked
}, loading)
}, loading, request)
.catch(function (err) {
if (request.cancelled) return;
setBusy(false, 'Error', true);
replaceLoadingMessage(loading, 'I could not complete the assistant request. ' + err.message + '\n\nThe indexed search service may be busy or temporarily unavailable. Please try again in a moment.');
if (typeof showToast === 'function') showToast(err.message, 'error');
})
.finally(function () {
if (activeAssistantRequest === request) activeAssistantRequest = null;
});
}
function createAssistantRequest() {
var controller = typeof AbortController === 'function' ? new AbortController() : null;
return {
cancelled: false,
signal: controller ? controller.signal : undefined,
abort: function () {
this.cancelled = true;
if (controller) controller.abort();
}
};
}
async function fetchAssistantResponse(payload, loading) {
updateLoadingMessage(loading, 'Looking up sources...');
var data = await fetchAssistantFallback(payload);
@ -154,8 +178,8 @@ import {
}
}
async function streamAssistantResponse(payload, loading) {
var response = await openAssistantStream(payload);
async function streamAssistantResponse(payload, loading, request) {
var response = await openAssistantStream(payload, { signal: request ? request.signal : undefined });
if (!response.ok || !response.body) {
var fallback = await response.json().catch(function () { return {}; });
throw new Error(fallback.error || ('Request failed (' + response.status + ')'));
@ -176,7 +200,7 @@ import {
if (!bubble) return;
loading.classList.remove('assistant-loading-msg');
bubble.classList.remove('assistant-thinking');
bubble.innerHTML = partial ? renderAssistantBubbleHtml(partial, streamSources, false) : '<p class="assistant-muted">Generating answer...</p>';
bubble.innerHTML = partial ? renderStreamingAnswerHtml(partial, streamSources) : '<p class="assistant-muted">Generating answer...</p>';
renderEmbeddedBlocks(bubble);
var wrap = document.getElementById('assistant-messages');
if (wrap) wrap.scrollTop = wrap.scrollHeight;
@ -206,6 +230,7 @@ import {
var reader = response.body.getReader();
while (true) {
if (request && request.cancelled) return;
var chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
@ -223,9 +248,11 @@ import {
if (!finalData) {
updateLoadingMessage(loading, 'Stream ended early. Retrying without streaming...');
finalData = await fetchAssistantFallback(payload);
finalData = await fetchAssistantFallback(payload, request);
}
if (request && request.cancelled) return;
setBusy(false, 'Ready');
lastAnswer = finalData.answer || finalData.markdown || '';
lastSources = finalData.sources || finalData.citations || streamSources;
@ -237,6 +264,20 @@ import {
}
}
function renderStreamingAnswerHtml(text, sources) {
if (shouldUseLightweightStreamingRender(text)) {
return '<pre class="assistant-streaming-text">' + escapeHtml(text) + '</pre>';
}
return renderAssistantBubbleHtml(text, sources, false);
}
function shouldUseLightweightStreamingRender(text) {
text = String(text || '');
if (text.length > STREAM_MARKDOWN_LIMIT) return true;
var pipeRows = text.split('\n').filter(function (line) { return /^\s*\|.*\|\s*$/.test(line); }).length;
return pipeRows >= 8;
}
function parseSseEvent(block) {
var type = 'message';
var data = '';
@ -249,8 +290,15 @@ import {
catch (e) { return null; }
}
async function fetchAssistantFallback(payload) {
var data = await fetchAssistantChat(payload);
async function fetchAssistantFallback(payload, request) {
var data;
try {
data = await fetchAssistantChat(payload, { signal: request ? request.signal : undefined });
} catch (e) {
if (request && request.cancelled) throw e;
if (e && e.name === 'AbortError') throw new Error('Assistant request cancelled.');
throw e;
}
if (!data.success) throw new Error(data.error || ('Request failed (' + data._status + ')'));
return data;
}
@ -431,17 +479,24 @@ import {
if (fromChat) setBusy(true, 'Generating image...');
var loading = fromChat ? appendLoadingMessage('Generating image', 'Creating the requested clinical visual...') : null;
if (out) out.innerHTML = '<p class="assistant-muted"><i class="fas fa-spinner fa-spin"></i> Generating image...</p>';
requestAssistantImage(prompt)
startAssistantImageJob(prompt)
.then(function (data) {
if (!data.success || !data.jobId) throw new Error(data.error || 'Image generation failed');
return waitForImageJob(data.jobId, function (status) {
if (out && status === 'running') out.innerHTML = '<p class="assistant-muted"><i class="fas fa-spinner fa-spin"></i> Generating image... You can leave the app open or return in a moment.</p>';
});
})
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Image generation failed');
var src = data.imageUrl || data.url || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
var src = data.base64 ? ('data:image/png;base64,' + data.base64) : (data.imageUrl || data.url || '');
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);
}
})
@ -453,6 +508,28 @@ import {
});
}
function waitForImageJob(jobId, onStatus) {
var started = Date.now();
var delay = 1200;
return new Promise(function (resolve, reject) {
function poll() {
fetchAssistantImageJob(jobId).then(function (data) {
if (!data.success) throw new Error(data.error || 'Image generation failed');
if (data.status === 'done') { resolve(data); return; }
if (data.status === 'error') { reject(new Error(data.error || 'Image generation failed')); return; }
if (typeof onStatus === 'function') onStatus(data.status || 'pending');
if (Date.now() - started > 180000) { reject(new Error('Image generation timed out. Please try again.')); return; }
delay = Math.min(delay + 300, 3500);
setTimeout(poll, delay);
}).catch(function (err) {
if (Date.now() - started > 180000) { reject(err); return; }
setTimeout(poll, delay);
});
}
poll();
});
}
function onAssistantDocumentClick(e) {
var loadBtn = e.target.closest('[data-assistant-load-chat]');
if (loadBtn) {
@ -472,6 +549,12 @@ import {
imageStore.openImagePreview(openBtn.getAttribute('data-assistant-open-image'));
return;
}
var downloadBtn = e.target.closest('[data-assistant-download-image]');
if (downloadBtn) {
e.preventDefault();
imageStore.downloadImage(downloadBtn.getAttribute('data-assistant-download-image'));
return;
}
if (e.target.closest('.assistant-image-modal-close') || e.target.classList.contains('assistant-image-modal')) {
imageStore.closeImagePreview();
}
@ -512,6 +595,17 @@ import {
loadSavedChats();
}
function cancelAssistantSearch() {
if (!activeAssistantRequest) return;
var request = activeAssistantRequest;
request.abort();
activeAssistantRequest = null;
setBusy(false, 'Ready');
if (request.loading && request.loading.parentNode) {
replaceLoadingMessage(request.loading, 'Search cancelled.');
}
}
function renderEmptyState() {
var examples = pickExamples();
return '<div class="assistant-empty"><i class="fas fa-book-medical"></i>' +
@ -723,6 +817,7 @@ import {
var status = document.getElementById('assistant-status');
var label = document.getElementById('assistant-status-text');
var send = document.getElementById('btn-assistant-send');
var cancel = document.getElementById('btn-assistant-cancel');
var input = document.getElementById('assistant-input');
if (status) {
status.classList.toggle('busy', !!isBusy);
@ -733,6 +828,13 @@ import {
send.disabled = !!isBusy;
send.innerHTML = isBusy ? '<i class="fas fa-spinner fa-spin"></i> Searching' : '<i class="fas fa-paper-plane"></i> Ask';
}
if (cancel) {
var canCancel = !!isBusy && !!activeAssistantRequest;
if (canCancel) cancel.removeAttribute('hidden');
else cancel.setAttribute('hidden', '');
cancel.disabled = !canCancel;
cancel.style.display = canCancel ? 'inline-flex' : 'none';
}
if (input) input.disabled = !!isBusy;
}

View file

@ -1,7 +1,7 @@
// E2E harness bootstrap — external file because the app's CSP blocks inline scripts.
// Runs after calc-math.js + calculators.js (all three have `defer`, so execution
// is in document order once parsing completes).
(async function bootstrap() {
async function bootstrapE2eHarness() {
try {
// Cache-bust so stale playwright browser caches never serve an outdated
// component HTML between reorg deploys.
@ -20,4 +20,5 @@
console.error('[e2e-harness] bootstrap failed:', e);
window.__harnessError = String(e);
}
})();
}
bootstrapE2eHarness();

View file

@ -205,6 +205,7 @@
'<div id="ed-final-note-text" class="output-text"></div>';
var t = container.querySelector('#ed-final-note-text');
if (t) t.textContent = note;
if (typeof attachPatientEducation === 'function') attachPatientEducation('ed-final-note-text', { patientAge: getVal('ed-age') });
container.classList.remove('hidden');
container.scrollIntoView({ behavior: 'smooth' });
}

View file

@ -2,7 +2,7 @@
// ENCOUNTERS.JS — Save/resume/pause encounter progress
// ============================================================
(function() {
function setupEncountersModule() {
// ── Pause/Resume for recording buttons ─────────────────────────────────
// Each recording module (encounter, dictation) gets a pause button wired here.
@ -94,14 +94,15 @@
}
// Restore saved encounter IDs from sessionStorage (survive page refresh, cleared on tab close)
(function() {
function restoreSavedEncounterIds() {
['encounter','dictation','ed','hospital','chart','wellvisit','sickvisit','soap'].forEach(function(t) {
try {
var id = sessionStorage.getItem('_savedEncId_' + t);
if (id) window['_savedEncId_' + t] = id;
} catch(e) {}
});
})();
}
restoreSavedEncounterIds();
// Register a load handler for a specific tab type
window.registerEncounterLoadHandler = function(type, fn) {
@ -513,4 +514,5 @@
console.log('✅ Encounters module loaded');
})();
}
setupEncountersModule();

View file

@ -6,6 +6,7 @@
var _mode = 'active'; // 'active' | 'trash'
var _editId = null; // null = creating, non-null = editing
var _searchDebounce = null;
var _pendingImportFile = null;
document.addEventListener('tabChanged', function (e) {
if (e.detail.tab !== 'extensions' || _inited) return;
@ -22,6 +23,8 @@
document.getElementById('ext-import-file').click();
});
document.getElementById('ext-import-file').addEventListener('change', importItems);
document.getElementById('ext-import-confirm').addEventListener('click', confirmImportPreview);
document.getElementById('ext-import-cancel').addEventListener('click', clearImportPreview);
document.getElementById('ext-trash-btn').addEventListener('click', toggleTrashMode);
document.getElementById('ext-back-active').addEventListener('click', function () { _mode = 'active'; updateModeBanner(); load(); });
@ -357,24 +360,65 @@
var input = e.target;
var file = input.files && input.files[0];
if (!file) return;
var count = 'the selected';
if (/\.json$/i.test(file.name)) {
count = 'the JSON';
} else if (/\.zip$/i.test(file.name)) {
count = 'the ZIP';
}
showConfirm(
'Import ' + count + ' pager/extension entries into your directory? Existing exact duplicates will be skipped.',
function () { submitImportFile(file, input); },
{ confirmText: 'Import' }
);
previewImportFile(file, input);
}
function submitImportFile(file, input) {
function previewImportFile(file, input) {
var form = new FormData();
form.append('file', file);
var headers = getAuthHeaders();
delete headers['Content-Type'];
fetch('/api/extensions/import-file/preview', { method: 'POST', headers: headers, body: form })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) { showToast(d.error || 'Import preview failed', 'error'); input.value = ''; return; }
_pendingImportFile = { file: file, input: input };
renderImportPreview(d.preview && d.preview.summary);
})
.catch(function () { showToast('Import preview failed', 'error'); input.value = ''; });
}
function renderImportPreview(summary) {
summary = summary || {};
var panel = document.getElementById('ext-import-preview');
var text = document.getElementById('ext-import-preview-text');
var restore = document.getElementById('ext-import-restore-trashed');
var possible = document.getElementById('ext-import-possible');
restore.checked = false;
possible.checked = false;
restore.disabled = !summary.exactTrashed;
possible.disabled = !summary.possible;
text.innerHTML =
'<strong>Import preview:</strong> ' + esc(summary.total || 0) + ' valid entries found. ' +
esc(summary.new || 0) + ' new, ' +
esc(summary.exactActive || 0) + ' exact active duplicates, ' +
esc(summary.exactTrashed || 0) + ' exact matches in trash, ' +
esc(summary.possible || 0) + ' possible duplicates by location/number or number/type. ' +
'Exact active duplicates are always skipped.';
panel.classList.remove('hidden');
}
function clearImportPreview() {
document.getElementById('ext-import-preview').classList.add('hidden');
if (_pendingImportFile && _pendingImportFile.input) _pendingImportFile.input.value = '';
_pendingImportFile = null;
}
function confirmImportPreview() {
if (!_pendingImportFile) return;
submitImportFile(_pendingImportFile.file, _pendingImportFile.input, {
restoreTrashed: document.getElementById('ext-import-restore-trashed').checked,
importPossibleDuplicates: document.getElementById('ext-import-possible').checked
});
}
function submitImportFile(file, input, options) {
var form = new FormData();
form.append('file', file);
form.append('restoreTrashed', options && options.restoreTrashed ? 'true' : 'false');
form.append('importPossibleDuplicates', options && options.importPossibleDuplicates ? 'true' : 'false');
var headers = getAuthHeaders();
delete headers['Content-Type'];
fetch('/api/extensions/import-file', {
method: 'POST',
headers: headers,
@ -383,7 +427,8 @@
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) { showToast(d.error || 'Import failed', 'error'); return; }
showToast('Imported ' + d.imported + ' entries' + (d.skipped ? ', skipped ' + d.skipped + ' duplicates' : ''), 'success');
showToast('Imported ' + d.imported + ' entries' + (d.restored ? ', restored ' + d.restored : '') + (d.skipped ? ', skipped ' + d.skipped : ''), 'success');
clearImportPreview();
load();
loadTrashCount();
})

View file

@ -175,6 +175,7 @@
clarifyOutput.classList.add('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('Hospital course generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('hc-course-text', { patientAge: document.getElementById('hc-age').value });
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('hc-course-text', data.hospitalCourse, 'hospital', document.getElementById('hc-age').value, document.getElementById('hc-setting').value);
} else showToast(data.error || 'Failed', 'error');
})

View file

@ -1,8 +1,7 @@
(function() {
var _inited = false;
var _liveEncounterInited = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail.tab !== 'encounter' || _inited) return;
_inited = true;
if (e.detail.tab !== 'encounter' || _liveEncounterInited) return;
_liveEncounterInited = true;
var recordBtn = document.getElementById('enc-record-btn');
var pauseBtn = document.getElementById('enc-pause-btn');
var indicator = document.getElementById('enc-recording-indicator');
@ -213,6 +212,7 @@
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('HPI generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('enc-hpi-text', { patientAge: document.getElementById('enc-age').value });
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('enc-hpi-text', data.hpi, 'hpi', document.getElementById('enc-age').value, document.getElementById('enc-setting').value);
if (typeof suggestDontMiss === 'function') suggestDontMiss('enc-hpi-text', data.hpi, 'hpi', document.getElementById('enc-age').value);
} else showToast(data.error || 'Failed', 'error');
@ -238,4 +238,3 @@
console.log('✅ Encounter module loaded');
});
})();

View file

@ -2,7 +2,7 @@
// SHADESS.JS — SSHADESS psychosocial screening form + well visit note
// ============================================================
(function() {
function setupShadessModule() {
// ── SSHADESS domain definitions ──────────────────────────────────────────
// Each domain has key questions (most important shown first) + comment field
@ -726,7 +726,7 @@
if (e.results[i].isFinal) _wvTranscript += e.results[i][0].transcript + ' ';
else interim = e.results[i][0].transcript;
}
if (transcriptEl) transcriptEl.innerHTML = _wvTranscript + (interim ? '<span style="color:#9ca3af;">' + interim + '</span>' : '');
if (transcriptEl) transcriptEl.innerHTML = esc(_wvTranscript) + (interim ? '<span style="color:#9ca3af;">' + esc(interim) + '</span>' : '');
};
_wvRecognition.onend = function() { if (_wvRecording && !_wvPaused) try { _wvRecognition.start(); } catch(e) {} };
}
@ -921,6 +921,7 @@
if (tag) tag.textContent = (data.model || '').split('/').pop();
if (outCard) { outCard.classList.remove('hidden'); outCard.scrollIntoView({ behavior: 'smooth' }); }
showToast('Well visit note generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('wv-note-text', { patientAge: document.getElementById('wv-note-age').value });
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('wv-note-text', data.note, 'wellvisit', document.getElementById('wv-note-age').value);
} else {
showToast(data.error || 'Generation failed', 'error');
@ -1058,4 +1059,5 @@
console.log('SHADESS module loaded');
})();
}
setupShadessModule();

View file

@ -335,6 +335,7 @@
if (tag) tag.textContent = (data.model || '').split('/').pop();
if (outCard) { outCard.classList.remove('hidden'); outCard.scrollIntoView({ behavior: 'smooth' }); }
showToast('Sick visit note generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('sick-note-text', { patientAge: document.getElementById('sick-age').value });
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('sick-note-text', data.note, 'sickvisit', document.getElementById('sick-age').value);
if (typeof suggestDontMiss === 'function') suggestDontMiss('sick-note-text', data.note, 'sickvisit', document.getElementById('sick-age').value, document.getElementById('sick-cc').value);
} else {

View file

@ -193,6 +193,7 @@
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('SOAP note generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('soap-text', { patientAge: document.getElementById('soap-age').value });
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('soap-text', data.soap, 'soap', document.getElementById('soap-age').value);
} else showToast(data.error || 'Failed', 'error');
})
@ -209,6 +210,7 @@
if (enc.generated_note) {
setOutputText(soapText, enc.generated_note);
outputCard.classList.remove('hidden');
if (typeof attachPatientEducation === 'function') attachPatientEducation('soap-text', { patientAge: document.getElementById('soap-age').value });
}
try {
var pd = JSON.parse(enc.partial_data || '{}');

View file

@ -1,8 +1,7 @@
(function() {
var _inited = false;
var _voiceDictationInited = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail.tab !== 'dictation' || _inited) return;
_inited = true;
if (e.detail.tab !== 'dictation' || _voiceDictationInited) return;
_voiceDictationInited = true;
var recordBtn = document.getElementById('dict-record-btn');
var pauseBtn = document.getElementById('dict-pause-btn');
var indicator = document.getElementById('dict-recording-indicator');
@ -187,6 +186,7 @@
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('Generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('dict-hpi-text', { patientAge: document.getElementById('dict-age').value });
} else showToast(data.error || 'Failed', 'error');
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
@ -202,10 +202,10 @@
if (enc.generated_note) {
hpiText.textContent = enc.generated_note;
outputCard.classList.remove('hidden');
if (typeof attachPatientEducation === 'function') attachPatientEducation('dict-hpi-text', { patientAge: document.getElementById('dict-age').value });
}
});
}
console.log('✅ Dictation module loaded');
});
})();

View file

@ -4,7 +4,9 @@
// API calls always fresh (critical for medical data accuracy)
// ============================================================
var CACHE_NAME = 'pedscribe-v12-notes7';
var CACHE_VERSION = 'dev';
try { CACHE_VERSION = new URL(self.location.href).searchParams.get('v') || CACHE_VERSION; } catch(e) {}
var CACHE_NAME = 'pedscribe-' + CACHE_VERSION;
var SHELL_ASSETS = [
'/',
'/index.html',

130
public/template-guide.md Normal file
View file

@ -0,0 +1,130 @@
# Ped-AI Template Guide
Use this guide to write templates that the AI can follow reliably. Save each template in Settings > My Templates under the matching category.
## General Rules
- Use clear section headings ending with a colon.
- Put one instruction or example per line.
- Separate formatting preferences from clinical facts.
- Do not include fake patient data unless it is clearly labeled as an example.
- Use bracketed placeholders for variable content, such as `[age]`, `[chief complaint]`, or `[follow-up interval]`.
- Tell the AI what to omit when data is missing.
## Good Heading Style
Chief Complaint:
History of Present Illness:
Review of Systems:
Physical Examination:
Assessment:
Plan:
## HPI Template Example
History of Present Illness:
Write one concise paragraph in third person.
Start with age, sex, historian, and chief complaint.
Use chronology first, then associated symptoms and pertinent negatives.
Only include OLDCARTS details if they were provided.
Do not invent fever height, duration, sick contacts, intake/output, severity, or home treatments.
## Sick Visit Template Example
Chief Complaint:
[chief complaint]
History of Present Illness:
One paragraph with onset, duration, progression, treatments tried, response, relevant exposures, and pertinent positives/negatives only if documented.
Review of Systems:
Include systems reviewed. Expand normal systems with brief relevant negatives. Do not list systems not reviewed.
Physical Examination:
Use system headings. Include only examined systems. Expand normal findings with specific exam language.
Assessment and Plan:
1. [diagnosis or clinical assessment]
Plan: [medications, supportive care, testing, return precautions, follow-up]
## Well Visit Template Example
Chief Complaint:
Well child visit.
Interval History:
Parent concerns, development, growth, diet, sleep, elimination, school/daycare, behavior, safety, and interval illness if discussed.
Review of Systems:
Use age-appropriate systems. Only include findings reviewed or documented.
Physical Examination:
Use system headings. Include vitals and measurements if provided.
Growth Assessment:
Comment on growth pattern, BMI or weight-for-length when available, and whether growth is appropriate or needs follow-up.
Screening Results:
Document completed screenings and results only.
Immunizations:
Vaccines given, deferred, or refused if documented.
Assessment:
1. Well child visit - [age]
2. Growth: [appropriate/concerning/deferred]
3. Development: [appropriate/concerning/deferred]
Plan:
Nutrition/feeding counseling.
Age-appropriate anticipatory guidance.
Vaccines and screenings.
Follow-up and next well visit.
## ED Template Example
Chief Complaint:
[chief complaint]
History of Present Illness:
Use chronology and relevant positives/negatives. Do not force every OLDCARTS element.
Physical Examination:
Focused exam with abnormal findings first when relevant.
ED Course:
Diagnostics, treatments, reassessments, consults, and response in chronological order.
Assessment and Plan:
Problem-oriented assessment. Include disposition only when documented.
## Physical Exam Template Example
General: Well-appearing, alert, interactive, in no acute distress.
HEENT: Normocephalic, atraumatic. TMs clear bilaterally. Oropharynx clear without erythema or exudate.
Neck: Supple, full range of motion, no lymphadenopathy.
Respiratory: Clear to auscultation bilaterally, no wheezes, crackles, or retractions.
Cardiovascular: Regular rate and rhythm, no murmur, brisk capillary refill.
Abdomen: Soft, non-tender, non-distended, normoactive bowel sounds.
Skin: Warm, dry, no rash.
Neurologic: Alert, age-appropriate, no focal deficit observed.
## Assessment And Plan Template Example
Assessment:
1. [Diagnosis or clinical problem]
Supporting findings: [brief rationale]
Plan:
Medications:
Testing:
Supportive care:
Return precautions:
Follow-up:
## Common Mistakes To Avoid
- Do not paste a complete note with fake patient facts and expect the AI to ignore them.
- Do not use vague instructions like "make it good" or "standard plan".
- Do not mix multiple note types in one template unless that is intentional.
- Do not include billing claims or diagnosis certainty that should depend on the encounter.

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

@ -176,7 +176,7 @@ function getTemplatedIndex() {
if (st.mtimeMs === _indexCached.mtime && _indexCached.html) return _indexCached.html;
var raw = fs.readFileSync(INDEX_PATH, 'utf8');
_indexCached.html = raw.replace(
/(<(?:script|link)[^>]+(?:src|href)=["'])(\/(?:js|css)\/[^"'?]+)(["'])/g,
/(<(?:script|link)[^>]+(?:src|href)=["'])(\/(?:js|css)\/[^"'?]+)(?:\?v=[^"']*)?(["'])/g,
'$1$2?v=' + BUILD_ID + '$3'
);
_indexCached.mtime = st.mtimeMs;
@ -320,6 +320,7 @@ app.use('/api', require('./src/routes/wellVisit'));
app.use('/api', require('./src/routes/sickVisit'));
app.use('/api', require('./src/routes/edEncounters'));
app.use('/api', require('./src/routes/dontMiss'));
app.use('/api', require('./src/routes/patientEducation'));
app.use('/api/user', require('./src/routes/userPreferences'));
app.use('/api/admin/learning', require('./src/routes/learningAI'));

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(
@ -436,7 +456,7 @@ async function initDatabase() {
// Boot sequence: inline init (idempotent baseline) → node-pg-migrate for
// incremental changes. Migrations live in /app/migrations/.
(async function() {
async function bootstrapDatabase() {
await initDatabase();
try {
var { runMigrations } = require('./migrate');
@ -447,7 +467,8 @@ async function initDatabase() {
// broken DB separately.
console.error('[DB] Migration runner failed:', e.message);
}
})();
}
bootstrapDatabase();
// Clean up expired saved encounters and audio backups
async function cleanupExpired() {

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