Compare commits
299 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b10dd0077 | ||
|
|
08d43298f3 | ||
|
|
9c7f1ba499 | ||
|
|
25e7869741 | ||
|
|
4b5d5b0be7 | ||
|
|
044ea765ae | ||
|
|
fccc3f7d16 | ||
|
|
34a53e35df | ||
|
|
c0f83c704e | ||
|
|
18a69d2a13 | ||
|
|
39675eedc0 | ||
|
|
46fe3ec29c | ||
|
|
931caae3b2 | ||
|
|
8a705ddf7b | ||
|
|
2a19f32f21 | ||
|
|
8ea79bcfb9 | ||
|
|
17aff3b4c3 | ||
|
|
b4a84ca7f1 | ||
|
|
b60ae9d9cc | ||
|
|
3704a0dca0 | ||
|
|
af7eb3b750 | ||
|
|
b21c921526 | ||
|
|
88b6b7ebce | ||
|
|
12a27b437d | ||
|
|
2ca4db1e8d | ||
|
|
685f0f93f1 | ||
|
|
77e78d662d | ||
|
|
b042f107f0 | ||
|
|
b942519d0c | ||
|
|
588b206ea1 | ||
|
|
5df760c5e5 | ||
|
|
8f49c4dcb9 | ||
|
|
503f5afaad | ||
|
|
560ec43f8b | ||
|
|
50e640149a | ||
|
|
7a1303e117 | ||
|
|
625afabfe9 | ||
|
|
fbae83940f | ||
|
|
93bb2c3a42 | ||
|
|
eec9950343 | ||
|
|
99b8bb65f6 | ||
|
|
355c2a999b | ||
|
|
4893099757 | ||
|
|
767821b810 | ||
|
|
552e137d0b | ||
|
|
75c54abfbc | ||
|
|
240f284570 | ||
|
|
8dba73a615 | ||
|
|
7f8255a728 | ||
|
|
ac39554c3a | ||
|
|
bc2580b148 | ||
|
|
3e3b4866be | ||
|
|
69dedbb635 | ||
|
|
5c1d1619c7 | ||
|
|
f4140d45c4 | ||
|
|
1431498fd6 | ||
|
|
8e1ab2fea3 | ||
|
|
c0cb66ae3e | ||
|
|
b9a3ed82b6 | ||
|
|
992bfac03b | ||
|
|
33826eb818 | ||
|
|
5ccce6e4d2 | ||
|
|
d20027f24f | ||
|
|
04762bded8 | ||
|
|
fe52ca0cf6 | ||
|
|
4f44a9e505 | ||
|
|
0148e567e3 | ||
|
|
3964147214 | ||
|
|
1011449f63 | ||
|
|
3ddd598d74 | ||
|
|
da9492a1ce | ||
|
|
12460d24ef | ||
|
|
2b403c9e46 | ||
|
|
4aa4ef0961 | ||
|
|
6ec9971621 | ||
|
|
466f02eb2e | ||
|
|
2392f4a089 | ||
|
|
132d321888 | ||
|
|
456b4d3232 | ||
|
|
b51cb1adb1 | ||
|
|
7f437b52a9 | ||
|
|
3a028e8f03 | ||
|
|
3fb1aa1e67 | ||
|
|
c159fe57b9 | ||
|
|
66ecc2246b | ||
|
|
27b1f6f089 | ||
|
|
46b3a2f678 | ||
|
|
958b35998e | ||
|
|
de5c75511b | ||
|
|
2456074481 | ||
|
|
71b717b533 | ||
|
|
601d35ef4c | ||
|
|
e1e5fbeabc | ||
|
|
da4dca06e1 | ||
|
|
0fa0c4846f | ||
|
|
09f8a1a3db | ||
|
|
ac0460b1fe | ||
|
|
9605262fe9 | ||
|
|
b5dbc98c75 | ||
|
|
86f16f914a | ||
|
|
9106644eb6 | ||
|
|
7a6758981f | ||
|
|
01b6c52e94 | ||
|
|
d26f8738eb | ||
|
|
359807b86e | ||
|
|
64731c21cd | ||
|
|
a63fd34edb | ||
|
|
031bfb995a | ||
|
|
2992ea1424 | ||
|
|
54285865d5 | ||
|
|
cd131e0b02 | ||
|
|
e54928f7f5 | ||
|
|
725e35bf96 | ||
|
|
201a51830c | ||
|
|
8097b0fe0b | ||
|
|
a76aead242 | ||
|
|
0ab48eeb98 | ||
|
|
d79d9eeded | ||
|
|
46b66a4507 | ||
|
|
30244276bf | ||
|
|
b64a39f8ea | ||
|
|
ce170f6fc1 | ||
|
|
5beb6cd562 | ||
|
|
4cb1080881 | ||
|
|
9a437c831c | ||
|
|
65a5dff9b4 | ||
|
|
0d6d91e8ef | ||
|
|
ed69fb0cc8 | ||
|
|
0b0bfc4a8a | ||
|
|
26857d52da | ||
|
|
ce466570ee | ||
|
|
c6d238c560 | ||
|
|
5439c1a742 | ||
|
|
6d6b4b90d2 | ||
|
|
0360685306 | ||
|
|
3b67d325fc | ||
|
|
2de10dc544 | ||
|
|
a125bf9e9c | ||
|
|
13eb968249 | ||
|
|
66ea127574 | ||
|
|
b03232c963 | ||
|
|
18811afbb5 | ||
|
|
7336e318be | ||
|
|
ab94239659 | ||
|
|
c98c571c66 | ||
|
|
907e131dc8 | ||
|
|
553449dbec | ||
|
|
d4546b7d02 | ||
|
|
f63d93807b | ||
|
|
ef6c90a889 | ||
|
|
09d07d7e0f | ||
|
|
87b2017919 | ||
|
|
c266ff2541 | ||
|
|
ec7e3d84b7 | ||
|
|
5888a9da0e | ||
|
|
ea03db3d45 | ||
|
|
63f77aa9cf | ||
|
|
8893e484fd | ||
|
|
dafbf44a32 | ||
|
|
a8992aee5a | ||
|
|
b5abbb69fc | ||
|
|
6febf6c914 | ||
|
|
37e58be5ec | ||
|
|
c7a04626a3 | ||
|
|
fc17032649 | ||
|
|
e161c221c4 | ||
|
|
6dffdf91e5 | ||
|
|
b294150781 | ||
|
|
cdf178b1c3 | ||
|
|
fa16cb13cb | ||
|
|
4a26abed10 | ||
|
|
d748dcc0d2 | ||
|
|
b23cb3300e | ||
|
|
9b407d1e18 | ||
|
|
e283bb8cda | ||
|
|
13e8937a00 | ||
|
|
5dde108e4a | ||
|
|
42984e355b | ||
|
|
3e05d8eec9 | ||
|
|
9bfadd7344 | ||
|
|
cb17a12172 | ||
|
|
93bc44b5e0 | ||
|
|
8409a49c74 | ||
|
|
942647871a | ||
|
|
369e440aa1 | ||
|
|
0c8a4db5c3 | ||
|
|
30300f169c | ||
|
|
5d988c397d | ||
|
|
e700ab1c8b | ||
|
|
6a690f6483 | ||
|
|
d29f55f8a6 | ||
|
|
04030b1ded | ||
|
|
bdf0916fe7 | ||
|
|
0630e460e8 | ||
|
|
64546a743d | ||
|
|
baa6362d29 | ||
|
|
7a957e856e | ||
|
|
f03ca5cb94 | ||
|
|
6978ed708c | ||
|
|
17a0371a0f | ||
|
|
7582e3563d | ||
|
|
d0d65446f6 | ||
|
|
aa33a55d0b | ||
|
|
d079c6d6c9 | ||
|
|
bf586daf4d | ||
|
|
ee88e51f14 | ||
|
|
4fbfc913d0 | ||
|
|
79994f4781 | ||
|
|
adf1365fa2 | ||
|
|
4fa2b58d75 | ||
|
|
04f3aa56cb | ||
|
|
020e831b3c | ||
|
|
a535ff6c15 | ||
|
|
a36235c646 | ||
|
|
f98b9b7b71 | ||
|
|
4fb038a745 | ||
|
|
215de4cac8 | ||
|
|
d86625c7e6 | ||
|
|
77eabbd4df | ||
|
|
970c946093 | ||
|
|
e459d34a13 | ||
|
|
b7adb4c3c7 | ||
|
|
a528bcc283 | ||
|
|
f95a03c13c | ||
|
|
0d33d3dce8 | ||
|
|
b8b9e8974b | ||
|
|
ca14094c0a | ||
|
|
b035f7d7b4 | ||
|
|
89daba420c | ||
|
|
7c27213451 | ||
|
|
cf4ba2a1e8 | ||
|
|
bbfe55f03b | ||
|
|
f18a87d0ff | ||
|
|
dd25d235d7 | ||
|
|
068cb258e9 | ||
|
|
d96a008dfe | ||
|
|
42daff2343 | ||
|
|
ef0f986c2f | ||
|
|
096d40f72d | ||
|
|
106e4baf17 | ||
|
|
0658b31df3 | ||
|
|
67c8638654 | ||
|
|
f126cf9fd7 | ||
|
|
2875e0cefd | ||
|
|
6db6a99eb2 | ||
|
|
ef80b75b6f | ||
|
|
f78f25e42f | ||
|
|
28fe1f520e | ||
|
|
f5ed67ccaf | ||
|
|
f2730bdc83 | ||
|
|
7e22902e47 | ||
|
|
d5d0ddcb95 | ||
|
|
6a7103a3f9 | ||
|
|
4808d08aa7 | ||
|
|
7a50bc061d | ||
|
|
63d8a881cb | ||
|
|
2423f4601e | ||
|
|
325575576c | ||
|
|
832fbc1283 | ||
|
|
d1138c8cc2 | ||
|
|
0ada98a13e | ||
|
|
38b1818148 | ||
|
|
1ab9878425 | ||
|
|
6f1bd97596 | ||
|
|
58c8f1c549 | ||
|
|
683afeea0b | ||
|
|
e4daa7590c | ||
|
|
9ec4cbf6b1 | ||
|
|
e9cab13c4f | ||
|
|
1371d705da | ||
|
|
364d564fca | ||
|
|
f609891d0d | ||
|
|
54c9aa1843 | ||
|
|
9e79a05676 | ||
|
|
268b6977cf | ||
|
|
72e91e940c | ||
|
|
35f03ac0ba | ||
|
|
28c3758eb6 | ||
|
|
cd3698f698 | ||
|
|
7d453094a0 | ||
|
|
88b8a5d418 | ||
|
|
ee60d269a5 | ||
|
|
007eef6887 | ||
|
|
044c809ff3 | ||
|
|
1191ba0d2d | ||
|
|
08a8fb26c4 | ||
|
|
5cad43d19a | ||
|
|
898036bfcd | ||
|
|
17646af5e3 | ||
|
|
25c462bfd6 | ||
|
|
6d1c2e5422 | ||
|
|
a53124a747 | ||
|
|
1f66b7c0e1 | ||
|
|
9758ecbea2 | ||
|
|
6e1b6ca3d7 | ||
|
|
eb63d9973d | ||
|
|
9eaec4f2de | ||
|
|
b997d6d388 | ||
|
|
7a2c569b63 |
246 changed files with 21687 additions and 38486 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
!.env.example
|
!.env.example
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
.agent-config
|
||||||
node_modules
|
node_modules
|
||||||
data/
|
data/
|
||||||
*.log
|
*.log
|
||||||
|
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
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
16
.github/pull_request_template.md
vendored
|
|
@ -1,16 +0,0 @@
|
||||||
## 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 #
|
|
||||||
1
.github/workflows/android-release.yml
vendored
1
.github/workflows/android-release.yml
vendored
|
|
@ -21,7 +21,6 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: ${{ github.server_url == 'https://github.com' }}
|
|
||||||
name: Build signed APK
|
name: Build signed APK
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
2
.github/workflows/auto-version.yml
vendored
2
.github/workflows/auto-version.yml
vendored
|
|
@ -31,7 +31,7 @@ permissions:
|
||||||
jobs:
|
jobs:
|
||||||
version:
|
version:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: "github.server_url == 'https://github.com' && !contains(github.event.head_commit.message, 'Release v') && !contains(github.event.head_commit.message, '[skip ci]')"
|
if: "!contains(github.event.head_commit.message, 'Release v') && !contains(github.event.head_commit.message, '[skip ci]')"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
|
||||||
1
.github/workflows/build-apk.yml
vendored
1
.github/workflows/build-apk.yml
vendored
|
|
@ -14,7 +14,6 @@ env:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-apk:
|
build-apk:
|
||||||
if: ${{ github.server_url == 'https://github.com' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
|
||||||
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
|
|
@ -1,34 +0,0 @@
|
||||||
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
|
|
||||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
|
|
@ -24,7 +24,6 @@ env:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: ${{ github.server_url == 'https://github.com' }}
|
|
||||||
# Build one variant per matrix entry, push by digest only.
|
# Build one variant per matrix entry, push by digest only.
|
||||||
name: Build ${{ matrix.platform }}
|
name: Build ${{ matrix.platform }}
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
|
|
@ -81,7 +80,6 @@ jobs:
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
merge:
|
merge:
|
||||||
if: ${{ github.server_url == 'https://github.com' }}
|
|
||||||
# Combine the two single-platform digests into one multi-arch manifest
|
# Combine the two single-platform digests into one multi-arch manifest
|
||||||
# published under the real tags (vX.Y.Z and latest).
|
# published under the real tags (vX.Y.Z and latest).
|
||||||
name: Merge manifests
|
name: Merge manifests
|
||||||
|
|
|
||||||
30
.github/workflows/security.yml
vendored
30
.github/workflows/security.yml
vendored
|
|
@ -1,30 +0,0 @@
|
||||||
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
|
|
||||||
1
.github/workflows/version-bump.yml
vendored
1
.github/workflows/version-bump.yml
vendored
|
|
@ -30,7 +30,6 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
bump:
|
bump:
|
||||||
if: ${{ github.server_url == 'https://github.com' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -39,5 +39,3 @@ e2e/playwright-report/
|
||||||
|
|
||||||
.codex
|
.codex
|
||||||
.firecrawl/
|
.firecrawl/
|
||||||
|
|
||||||
# Refactored test stack stays local for now
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ or Actions tab → **Version bump & release** → Run workflow → pick bump typ
|
||||||
|
|
||||||
| Workflow | Output |
|
| Workflow | Output |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `.forgejo/workflows/android-apk.yml` | signed APK on Forgejo release (`pedscribe-<tag>.apk`), optional Google Play internal track upload |
|
| `android-release.yml` | signed APK on GitHub release, `make_latest=true` |
|
||||||
| `docker-publish.yml` | `danielonyejesi/pediatric-ai-scribe-v3:{version,latest}` on Docker Hub (amd64) |
|
| `docker-publish.yml` | `danielonyejesi/pediatric-ai-scribe-v3:{version,latest}` on Docker Hub (amd64) |
|
||||||
|
|
||||||
## Local dev
|
## Local dev
|
||||||
|
|
|
||||||
19
Dockerfile
19
Dockerfile
|
|
@ -8,7 +8,7 @@ FROM node:20-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# ffmpeg: audio conversion for AWS Transcribe (WebM → PCM)
|
# ffmpeg: audio conversion for AWS Transcribe (WebM → PCM)
|
||||||
# curl: HTTP helper used by the OpenBao entrypoint and health/debug tooling
|
# curl: download Whisper models for browser-based transcription
|
||||||
# jq: JSON parsing for the entrypoint's OpenBao secret-fetch step
|
# jq: JSON parsing for the entrypoint's OpenBao secret-fetch step
|
||||||
RUN apk add --no-cache ffmpeg curl jq
|
RUN apk add --no-cache ffmpeg curl jq
|
||||||
|
|
||||||
|
|
@ -30,6 +30,22 @@ RUN chmod +x /app/docker-entrypoint.sh
|
||||||
|
|
||||||
RUN mkdir -p /app/data/logs
|
RUN mkdir -p /app/data/logs
|
||||||
|
|
||||||
|
# Download Browser Whisper (COMPLETE self-hosting - zero CDN dependencies)
|
||||||
|
# Library + Models all bundled and served from our server
|
||||||
|
RUN mkdir -p /app/public/models/Xenova/whisper-tiny.en/onnx && \
|
||||||
|
cd /app/public/models && \
|
||||||
|
echo "Downloading transformers.js library (worker-compatible build)..." && \
|
||||||
|
curl -sL -o transformers.min.js https://cdn.jsdelivr.net/npm/@xenova/transformers@2.0.0/dist/transformers.min.js && \
|
||||||
|
cd Xenova/whisper-tiny.en && \
|
||||||
|
echo "Downloading Whisper model files..." && \
|
||||||
|
curl -sL -o config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/config.json && \
|
||||||
|
curl -sL -o tokenizer.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/tokenizer.json && \
|
||||||
|
curl -sL -o preprocessor_config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/preprocessor_config.json && \
|
||||||
|
curl -sL -o generation_config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/generation_config.json && \
|
||||||
|
curl -sL -o onnx/encoder_model_quantized.onnx https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/encoder_model_quantized.onnx && \
|
||||||
|
curl -sL -o onnx/decoder_model_merged_quantized.onnx https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/decoder_model_merged_quantized.onnx && \
|
||||||
|
echo "✅ Browser Whisper: 100% self-hosted (library: 760KB, models: 42MB)"
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||||
|
|
@ -40,3 +56,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||||
# unset, so legacy .env-only deployments continue to work unchanged.
|
# unset, so legacy .env-only deployments continue to work unchanged.
|
||||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|
||||||
|
|
|
||||||
430
README.md
430
README.md
|
|
@ -1,103 +1,78 @@
|
||||||
# Ped-AI
|
# Pediatric AI Scribe v6
|
||||||
|
|
||||||
Ped-AI is a pediatric clinical documentation, education, and bedside decision-support app. This fork has moved well beyond the original scribe app: it now combines encounter documentation, clinical workflows, Learning Hub CMS, admin controls, MCP-backed clinical assistant integration, Redis-backed operational state, and hardened deployment defaults.
|
AI-powered clinical documentation platform for pediatric medicine. Generates HPIs, hospital courses, chart reviews, SOAP notes, well/sick visit notes, and developmental milestone assessments from voice recordings or dictation.
|
||||||
|
|
||||||
The app runs as an authenticated Express/Postgres service with a browser frontend and optional integrations for LiteLLM, Vertex/Gemini, AWS, OpenAI-compatible APIs, Nextcloud WebDAV, S3-compatible storage, OpenBao, Redis, OIDC, TOTP, and Cloudflare Turnstile.
|
## Features
|
||||||
|
|
||||||
## Current Scope
|
|
||||||
|
|
||||||
### Clinical Documentation
|
### Clinical Documentation
|
||||||
|
- **Live Encounter** — record doctor-patient conversations, AI generates structured OLDCARTS HPI
|
||||||
|
- **Voice Dictation** — dictate narrative, AI cleans and restructures
|
||||||
|
- **Hospital Course** — paste progress notes, generates prose, day-by-day, organ-system (ICU), or psych format
|
||||||
|
- **Chart Review / Precharting** — summarize outpatient, subspecialty, and ED notes
|
||||||
|
- **SOAP Notes** — full SOAP or subjective-only from dictation
|
||||||
|
- **Well Visit** — AAP 2025 Bright Futures periodicity with vaccines, screenings, billing codes, SSHADESS (12+), milestones
|
||||||
|
- **Sick Visit** — quick documentation with auto-suggested ROS and PE from chief complaint
|
||||||
|
- **Developmental Milestones** — AAP/Nelson tracker (birth-11y) with narrative/structured/summary output
|
||||||
|
|
||||||
- Live encounter capture with structured pediatric HPI generation.
|
### AI & Speech
|
||||||
- Dictation cleanup for narrative notes.
|
- **5 AI Providers** — OpenRouter, AWS Bedrock, Azure OpenAI, Google Vertex AI, LiteLLM
|
||||||
- SOAP, sick visit, well visit, hospital course, chart review, precharting, and ED encounter workflows.
|
- **5 STT Providers** — Google Gemini, Amazon Transcribe (Medical), OpenAI Whisper, Local Whisper, LiteLLM
|
||||||
- Parent-facing education handouts generated from clinician notes, with diagnosis, medication, emergency-care guidance, and preferred-language support.
|
- **3 TTS Providers** — Google Cloud TTS, LiteLLM (OpenAI), ElevenLabs
|
||||||
- Pediatric developmental milestone tooling.
|
- **Browser Whisper** — fully offline in-browser transcription via WebAssembly (HIPAA-safe)
|
||||||
- Templates, physician memory, and per-tab model overrides.
|
- **Per-tab model selector** — choose fast vs. smart vs. premium models per task
|
||||||
- Server-side speech-to-text routing through configured providers.
|
- **Physician memory system** — Dragon-like learning from your corrections
|
||||||
|
|
||||||
### Bedside Tools
|
|
||||||
|
|
||||||
- Pediatric calculators and emergency dosing helpers.
|
|
||||||
- PE guide and clinical reference content.
|
|
||||||
- Vaccines, catch-up schedules, growth/vitals, bilirubin, BSA, GCS, equipment, and resuscitation helpers.
|
|
||||||
- Mobile-friendly PWA layout for bedside use.
|
|
||||||
- Per-user phone extension and pager directory with soft-delete, search, ZIP export, and JSON/ZIP import for handoff between users.
|
|
||||||
|
|
||||||
### Learning Hub
|
### Learning Hub
|
||||||
|
- **Content Management** — articles, clinical pearls, quizzes, presentations
|
||||||
|
- **AI Content Generation** — generate from topics, uploaded PDFs, or Nextcloud files
|
||||||
|
- **Marp Presentations** — slide editor with preview and PPTX export
|
||||||
|
- **Semantic Search** — vector-based search via pgvector embeddings
|
||||||
|
- **Quiz System** — MCQ, multi-select, true/false with scoring and progress tracking
|
||||||
|
|
||||||
- CMS for articles, clinical pearls, quizzes, and presentations.
|
### Platform
|
||||||
- Tiptap article editor, quiz builder, category management, and draft/publish flow.
|
- **Multi-user with roles** — admin, moderator, user
|
||||||
- AI-assisted content generation from topic text, uploaded files, or connected Nextcloud WebDAV files.
|
- **OIDC/SSO** — Azure AD, Okta, Keycloak, PocketID, Google
|
||||||
- Marp slide editing with preview and PPTX export.
|
- **2FA** — TOTP-based two-factor authentication
|
||||||
- Keyword, semantic, and hybrid search using Postgres/pgvector where configured.
|
- **Cloudflare Turnstile** — bot protection on login, register, password reset
|
||||||
|
- **Email verification** — with customizable templates
|
||||||
|
- **Nextcloud integration** — WebDAV export
|
||||||
|
- **S3 Document Storage** — AWS S3, Backblaze B2, MinIO
|
||||||
|
- **PWA** — installable, works on mobile
|
||||||
|
- **Admin Panel** — user management, settings, prompt editor, model configuration, logs
|
||||||
|
|
||||||
### Clinical Assistant
|
---
|
||||||
|
|
||||||
- Optional MCP-backed clinical assistant integration.
|
|
||||||
- Prompt suggestions backed by Redis operational cache.
|
|
||||||
- No clinical answer response caching.
|
|
||||||
- Designed to retrieve from indexed clinical material while keeping provider selection explicit.
|
|
||||||
|
|
||||||
### Admin And Security
|
|
||||||
|
|
||||||
- Local auth, role-based access, TOTP 2FA, OIDC/SSO, email verification, and optional Turnstile.
|
|
||||||
- Admin panel for users, settings, prompts, models, logs, and Learning Hub content.
|
|
||||||
- Audit, API, access, and client-error logs with redaction hardening.
|
|
||||||
- OpenBao secret loading support at container startup.
|
|
||||||
- S3-compatible document storage support.
|
|
||||||
|
|
||||||
## Removed Browser STT
|
|
||||||
|
|
||||||
Browser Whisper has been removed from the runtime. The app should not ship browser Whisper workers, browser-local Whisper model downloads, Transformers.js browser STT, or Browser Whisper setup docs.
|
|
||||||
|
|
||||||
Speech-to-text is handled server-side through configured providers such as Google/Gemini, AWS Transcribe, LiteLLM, or OpenAI Whisper. Browser-native Web Speech remains gated behind an explicit user setting when present in the browser.
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Configure
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose up -d --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The default compose exposes the app on `127.0.0.1:3552` and starts:
|
Edit `.env` — at minimum set:
|
||||||
|
|
||||||
- `pediatric-ai-scribe` for the Node app.
|
|
||||||
- `pedscribe-db` for Postgres with pgvector.
|
|
||||||
- `ped-ai-redis` for operational Redis state.
|
|
||||||
|
|
||||||
Health check:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsS http://127.0.0.1:3552/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
Prometheus metrics are exposed at `GET /metrics` with the `ped_ai_` metric prefix.
|
|
||||||
|
|
||||||
The first registered user becomes an admin unless registration has already been configured differently.
|
|
||||||
|
|
||||||
## Core Environment
|
|
||||||
|
|
||||||
Set real values in `.env` before production use.
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
APP_URL=https://your-domain.example
|
AI_PROVIDER=litellm # or openrouter, bedrock, azure, vertex
|
||||||
JWT_SECRET=<64-char-random-secret>
|
LITELLM_API_BASE=https://your-litellm.example.com
|
||||||
DB_PASSWORD=<strong-database-password>
|
LITELLM_API_KEY=sk-...
|
||||||
|
|
||||||
AI_PROVIDER=litellm
|
OPENAI_API_KEY=sk-... # for Whisper transcription (if not using LiteLLM STT)
|
||||||
LITELLM_API_BASE=https://your-litellm.example/v1
|
|
||||||
LITELLM_API_KEY=<key>
|
|
||||||
|
|
||||||
TRANSCRIBE_PROVIDER=litellm
|
JWT_SECRET=<64-char random> # openssl rand -hex 32
|
||||||
LITELLM_STT_MODEL=whisper-1
|
DB_PASSWORD=<strong password>
|
||||||
|
APP_URL=https://your-domain.com
|
||||||
REDIS_URL=redis://ped-ai-redis:6379
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported text AI providers include LiteLLM, OpenRouter, AWS Bedrock, Azure OpenAI, and Google Vertex AI. Supported STT routing includes Google/Gemini, AWS Transcribe, OpenAI Whisper, and LiteLLM. Supported TTS routing includes Google Cloud TTS, LiteLLM/OpenAI-compatible audio, and ElevenLabs where configured.
|
### 2. Start
|
||||||
|
|
||||||
## Admin CLI
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
App runs on **port 3552**. First user to register becomes admin.
|
||||||
|
|
||||||
|
### 3. Admin CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec pediatric-ai-scribe node admin-cli.js list-users
|
docker exec pediatric-ai-scribe node admin-cli.js list-users
|
||||||
|
|
@ -108,70 +83,293 @@ docker exec pediatric-ai-scribe node admin-cli.js toggle-registration
|
||||||
docker exec pediatric-ai-scribe node admin-cli.js stats
|
docker exec pediatric-ai-scribe node admin-cli.js stats
|
||||||
```
|
```
|
||||||
|
|
||||||
## Maintenance
|
---
|
||||||
|
|
||||||
The app checks Postgres collation drift on startup and can reindex text indexes after image or OS-library changes.
|
## AI Provider Configuration
|
||||||
|
|
||||||
|
Switch providers by setting `AI_PROVIDER` in `.env`. No code changes needed.
|
||||||
|
|
||||||
|
| Provider | HIPAA | Config |
|
||||||
|
|----------|-------|--------|
|
||||||
|
| **LiteLLM** | Depends on backend | `LITELLM_API_BASE`, `LITELLM_API_KEY` |
|
||||||
|
| **AWS Bedrock** | Yes (with BAA) | `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` |
|
||||||
|
| **Azure OpenAI** | Yes (with BAA) | `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_DEPLOYMENT_NAME` |
|
||||||
|
| **Google Vertex AI** | Yes (with BAA) | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_VERTEX_LOCATION` |
|
||||||
|
| **OpenRouter** | No | `OPENROUTER_API_KEY` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transcription (Speech-to-Text)
|
||||||
|
|
||||||
|
Set `TRANSCRIBE_PROVIDER` or let the app auto-detect.
|
||||||
|
|
||||||
|
| Provider | HIPAA | Config |
|
||||||
|
|----------|-------|--------|
|
||||||
|
| **Google Gemini** | Yes | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_STT_MODEL` |
|
||||||
|
| **Amazon Transcribe** | Yes | AWS creds + `TRANSCRIBE_PROVIDER=aws` |
|
||||||
|
| **Amazon Transcribe Medical** | Yes | `AWS_TRANSCRIBE_MEDICAL=true`, `AWS_TRANSCRIBE_SPECIALTY=PRIMARYCARE` |
|
||||||
|
| **Local Whisper** | Yes (offline) | `TRANSCRIBE_PROVIDER=local`, `WHISPER_BINARY`, `WHISPER_MODEL_SIZE` |
|
||||||
|
| **OpenAI Whisper** | No | `OPENAI_API_KEY` |
|
||||||
|
| **LiteLLM** | Depends | `TRANSCRIBE_PROVIDER=litellm`, `LITELLM_STT_MODEL` |
|
||||||
|
| **Browser Whisper** | Yes (client-side) | No config needed — toggle in user settings |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Text-to-Speech
|
||||||
|
|
||||||
|
| Provider | HIPAA | Config |
|
||||||
|
|----------|-------|--------|
|
||||||
|
| **Google Cloud TTS** | Yes | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_TTS_VOICE` |
|
||||||
|
| **LiteLLM** | Depends | `LITELLM_TTS_MODEL`, `LITELLM_TTS_VOICE` |
|
||||||
|
| **ElevenLabs** | No | `ELEVENLABS_API_KEY` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenID Connect / SSO
|
||||||
|
|
||||||
|
Supports Azure AD, Okta, Keycloak, PocketID, Google, and any OIDC-compliant provider.
|
||||||
|
|
||||||
|
1. Register callback URL: `https://your-domain.com/api/auth/oidc/callback`
|
||||||
|
2. Admin Panel > Settings > Configure OIDC (Issuer URL, Client ID, Client Secret)
|
||||||
|
3. Users are auto-created and linked by email on first SSO login
|
||||||
|
|
||||||
|
See [docs/openid-setup.md](docs/openid-setup.md) for provider-specific guides.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cloudflare Turnstile (Bot Protection)
|
||||||
|
|
||||||
|
Optional CAPTCHA on login, registration, and password reset forms.
|
||||||
|
|
||||||
|
```env
|
||||||
|
TURNSTILE_SITE_KEY=0x4AAA...
|
||||||
|
TURNSTILE_SECRET_KEY=0x4AAA...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email
|
||||||
|
|
||||||
|
Without SMTP, email verification is skipped and users are auto-verified.
|
||||||
|
|
||||||
|
```env
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASS=your-app-password
|
||||||
|
SMTP_FROM=noreply@yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Maintenance CLI
|
||||||
|
|
||||||
|
After a Postgres image upgrade (major version bump or silent base-layer change),
|
||||||
|
btree indexes on text columns can become inconsistent with the new ICU/glibc
|
||||||
|
library. The app auto-detects this at startup and reindexes on drift, but you
|
||||||
|
can also trigger it manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Health check — no writes
|
||||||
docker exec pediatric-ai-scribe npm run maint:check
|
docker exec pediatric-ai-scribe npm run maint:check
|
||||||
|
|
||||||
|
# Rebuild all indexes + refresh collation + ANALYZE
|
||||||
docker exec pediatric-ai-scribe npm run maint:reindex
|
docker exec pediatric-ai-scribe npm run maint:reindex
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the reindex command after major Postgres image changes, restoring a dump from another distro, or seeing lookup behavior that suggests collation/index drift.
|
Run `maint:reindex` any time after:
|
||||||
|
|
||||||
|
- Upgrading the Postgres image (major or minor)
|
||||||
|
- Restoring from a dump created on a different Linux distro
|
||||||
|
- Seeing "invalid credentials" on credentials you know are correct
|
||||||
|
- Seeing `0 rows` returned from a lookup that should match
|
||||||
|
|
||||||
|
The reindex takes seconds on a small DB and a minute or two on larger ones.
|
||||||
|
Safe to run while the app is serving traffic, though queries may slow briefly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Hub
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull danielonyejesi/pediatric-ai-scribe-v3:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Minimal compose without building:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: danielonyejesi/pediatric-ai-scribe-v3:latest
|
||||||
|
ports:
|
||||||
|
- "3552:3000"
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: pedscribe
|
||||||
|
POSTGRES_USER: pedscribe
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U pedscribe"]
|
||||||
|
interval: 10s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HIPAA Notice
|
||||||
|
|
||||||
|
This application processes data through third-party AI APIs.
|
||||||
|
|
||||||
|
- All connections use HTTPS/TLS
|
||||||
|
- Authentication required for all AI endpoints
|
||||||
|
- 2FA and SSO available
|
||||||
|
- Cloudflare Turnstile bot protection
|
||||||
|
- **AWS Bedrock**, **Azure OpenAI**, and **Google Vertex AI** offer BAAs
|
||||||
|
- **OpenRouter** and **ElevenLabs** do NOT offer BAAs
|
||||||
|
- **Browser Whisper** and **Local Whisper** keep audio fully private
|
||||||
|
|
||||||
|
**Do not use real PHI without executed BAAs with all providers in your deployment.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
See [docs/](docs/) for the full documentation set.
|
||||||
|
|
||||||
|
### Application logic — start here if you're new to the codebase
|
||||||
|
|
||||||
|
[**docs/logic/**](docs/logic/) is a deep, dev-friendly walkthrough of how each
|
||||||
|
part of the app actually works. ~8,300 lines of "how it works and why" — read
|
||||||
|
the index first to know what's there:
|
||||||
|
|
||||||
|
- [docs/logic/README.md](docs/logic/README.md) — index + recommended reading order
|
||||||
|
- [docs/logic/architecture.md](docs/logic/architecture.md) — frontend IIFE pattern, lazy tab loading, backend route convention, schema, encryption, sacred zones
|
||||||
|
- [docs/logic/clinical-notes.md](docs/logic/clinical-notes.md) — every note tab (HPI, dictation, sick, well, SOAP, hospital, chart, notes) with the shared record→generate→save lifecycle
|
||||||
|
- [docs/logic/ed-encounters.md](docs/logic/ed-encounters.md) — multi-stage ED notes, per-stage don't-miss, consolidate→MDM finalize. Worked example of how a clinical workflow is composed in this codebase.
|
||||||
|
- [docs/logic/bedside-and-calculators.md](docs/logic/bedside-and-calculators.md) — Bedside emergencies module (ES-module pocket of the frontend), pediatric calculators, PE Guide, suture selector. Lists every clinical formula that must NOT be modified without test vectors.
|
||||||
|
- [docs/logic/ai-and-voice.md](docs/logic/ai-and-voice.md) — `callAI` 5-provider routing, prompt centralization with DB overrides, `wrapUserText`+`INJECTION_GUARD`, server STT routing, browser Whisper, the helper trio (refine/billing/don't-miss).
|
||||||
|
- [docs/logic/auth-admin-learning.md](docs/logic/auth-admin-learning.md) — local + OIDC auth, 2FA, sessions, OpenBao secret loading, Admin panel, Learning Hub.
|
||||||
|
|
||||||
|
### Operational + reference
|
||||||
|
|
||||||
|
- [Architecture Overview](docs/architecture.md) — high-level (the deep version is in docs/logic/architecture.md)
|
||||||
|
- [API Reference](docs/api-reference.md)
|
||||||
|
- [Database Schema](docs/database.md)
|
||||||
|
- [Authentication & Security](docs/authentication.md)
|
||||||
|
- [AI Providers & Models](docs/ai-providers.md)
|
||||||
|
- [Speech (STT/TTS)](docs/speech.md)
|
||||||
|
- [Learning Hub & CMS](docs/learning-hub.md)
|
||||||
|
- [Configuration Reference](docs/configuration.md)
|
||||||
|
- [Deployment Guide](docs/deployment.md)
|
||||||
|
- [Developer Guide (short)](docs/developer-guide.md)
|
||||||
|
- [Developer Guide (extended)](docs/developer-guide-extended.md)
|
||||||
|
- [Browser Whisper Setup](docs/browser-whisper-setup.md) · [Troubleshooting](docs/browser-whisper-troubleshooting.md)
|
||||||
|
- [Embeddings Setup](docs/embeddings-setup.md)
|
||||||
|
- [OpenID Connect Setup](docs/openid-setup.md)
|
||||||
|
- [Transcription Options](docs/transcription-options.md)
|
||||||
|
- [Features Explained](docs/features-explained.md)
|
||||||
|
- [Improvement Roadmap](docs/improvements.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env # edit with your keys
|
||||||
|
# Requires PostgreSQL with pgvector
|
||||||
|
node server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Run the Node test suite:
|
Two layers, both zero-config after the initial setup.
|
||||||
|
|
||||||
|
### Unit tests — pure dose math (Node built-in)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
Run syntax checks for touched files when doing focused backend work:
|
Runs `node --test test/` against `public/js/calc-math.js` — pure functions for
|
||||||
|
APLS / Best Guess weight, Parkland, Holliday-Segar 4-2-1, PRAM, Westley,
|
||||||
```bash
|
epi (anaphylaxis vs arrest vs NRP, different concentrations), RSI drugs,
|
||||||
node --check server.js
|
min SBP, ETT sizing, Lund-Browder TBSA. **36 assertions, no dependencies.**
|
||||||
node --check src/routes/transcribe.js
|
|
||||||
```
|
### End-to-end tests — Playwright smoke suite
|
||||||
|
|
||||||
Run the Playwright smoke suite against the e2e compose stack:
|
Runs a headless Chromium against the live app. **128 tests** covering every
|
||||||
|
calculator tab, every Bedside sub-pill + widget, auth-gated pages (encounter,
|
||||||
|
well visit, charts, vaccines, catch-up, learning hub, dictation, settings,
|
||||||
|
FAQ), at **both desktop and mobile (Pixel 5) viewports**.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# First-time setup: spin up the auth-less test container (port 3553)
|
||||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d pediatric-scribe-e2e
|
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d pediatric-scribe-e2e
|
||||||
|
|
||||||
|
# Then run the full suite (runs inside an official Playwright container)
|
||||||
npm run e2e
|
npm run e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment Notes
|
The runner script (`scripts/e2e.sh`) uses `mcr.microsoft.com/playwright` so you
|
||||||
|
don't need Node or browsers on the host.
|
||||||
|
|
||||||
- Put the app behind HTTPS before clinical use.
|
**Test environment:**
|
||||||
- Use only AI/STT/TTS providers covered by your BAA and data-processing requirements.
|
|
||||||
- Configure OIDC/SSO and 2FA for production users.
|
|
||||||
- Keep `JWT_SECRET`, database credentials, provider keys, S3 keys, SMTP credentials, and OpenBao tokens out of git.
|
|
||||||
- Treat logs as sensitive operational data even with redaction enabled.
|
|
||||||
- Use the Caddy/reverse-proxy layer to expose only intended public routes.
|
|
||||||
|
|
||||||
## Documentation
|
- `pediatric-ai-scribe` (port 3552) — your normal app
|
||||||
|
- `pediatric-ai-scribe-e2e` (port 3553) — identical image, but with
|
||||||
|
`TURNSTILE_SECRET_KEY=""` and `SMTP_HOST=""` so Playwright can log in
|
||||||
|
without a bot challenge. Shares the same Postgres + pgdata volume.
|
||||||
|
- Test user: `e2e-user@ped-ai.test` (auto-verified on first register)
|
||||||
|
- Harness page: `public/e2e-harness.html` loads the calculators component
|
||||||
|
without the auth wall for smoke tests that don't need a logged-in session.
|
||||||
|
|
||||||
Primary references:
|
**Viewing failures** — Playwright writes `e2e/test-results/<test-name>/`
|
||||||
|
with:
|
||||||
|
|
||||||
- `docs/ARCHITECTURE.md` for the current system map and service boundaries.
|
- `test-failed-1.png` — screenshot at the point of failure
|
||||||
- `docs/DEVELOPMENT.md` for day-to-day code-change workflow.
|
- `trace.zip` — full action trace (replay with `npx playwright show-trace`)
|
||||||
- `docs/SCALING.md` for scaling priorities and readiness work.
|
- `error-context.md` — DOM snapshot and console logs
|
||||||
- `docs/CLINICAL_ASSISTANT.md` for MCP-backed assistant behavior and safety rules.
|
|
||||||
- `docs/MODULE_CONVENTIONS.md` for CommonJS, ESM, globals, and rendering rules.
|
|
||||||
- `docs/architecture.md` for high-level architecture.
|
|
||||||
- `docs/api-reference.md` for API routes.
|
|
||||||
- `docs/authentication.md` for auth, OIDC, and security configuration.
|
|
||||||
- `docs/ai-providers.md` for model/provider setup.
|
|
||||||
- `docs/speech.md` for server-side STT/TTS setup.
|
|
||||||
- `docs/learning-hub.md` for the CMS and education workflow.
|
|
||||||
- `docs/configuration.md` for environment variables.
|
|
||||||
- `docs/deployment.md` for production deployment.
|
|
||||||
- `docs/mobile-build.md` for the Capacitor wrapper and app-store build notes.
|
|
||||||
- `docs/logic/README.md` for the deeper code walkthrough.
|
|
||||||
|
|
||||||
Some deep `docs/logic/` files still describe historical implementation details. Prefer runtime code and tests when documentation conflicts with current behavior.
|
Everything but the specs and config is gitignored under `e2e/`.
|
||||||
|
|
||||||
## Clinical Safety
|
**Files:**
|
||||||
|
|
||||||
Ped-AI is documentation and education support software. It does not replace clinical judgment, local policy, medication verification, or attending review. Validate generated notes, calculations, and recommendations before use in patient care.
|
- `e2e/tests/bedside-smoke.spec.js` — 26 tests for the Bedside module
|
||||||
|
- `e2e/tests/top-calculators.spec.js` — 27 tests for BP / BMI / Growth /
|
||||||
|
Bili / Vitals / BSA / Dose / Resus / GCS / Equipment
|
||||||
|
- `e2e/tests/auth-gated-smoke.spec.js` — 11 tests for the auth-gated tabs
|
||||||
|
- `e2e/playwright.config.js` — runs all the above under both `chromium`
|
||||||
|
(Desktop Chrome) and `mobile-chrome` (Pixel 5) projects
|
||||||
|
|
||||||
|
**Writing a new test:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test('my new smoke test', async ({ page }) => {
|
||||||
|
await page.goto('/e2e-harness.html'); // bypasses auth for calculators
|
||||||
|
await page.waitForFunction(() => window.__harnessReady === true);
|
||||||
|
await page.click('button.calc-nav-pill[data-calc="bedside"]');
|
||||||
|
await expect(page.locator('#calc-bedside')).toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
For auth-gated routes, use the login fixture in `auth-gated-smoke.spec.js`
|
||||||
|
as a template — it caches the token at module scope so you don't hit the
|
||||||
|
login rate-limit.
|
||||||
|
|
|
||||||
|
|
@ -8,29 +8,17 @@ services:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
CLINICAL_ASSISTANT_MCP_URL: http://mcp:8000/mcp
|
CLINICAL_ASSISTANT_MCP_URL: http://mcp:8000/mcp
|
||||||
REDIS_URL: redis://ped-ai-redis:6379
|
|
||||||
LOKI_URL: http://monitoring-loki:3100
|
|
||||||
LITELLM_API_BASE: http://litellm:4000
|
|
||||||
TTS_PROVIDER: litellm
|
|
||||||
LITELLM_TTS_MODEL: local-kokoro-tts
|
|
||||||
LITELLM_TTS_VOICE: sherpa/kokoro:am_adam
|
|
||||||
LITELLM_TTS_VOICES: sherpa/kokoro:am_adam,sherpa/kokoro:am_michael,sherpa/kokoro:af_bella,sherpa/kokoro:af_nicole,sherpa/kokoro:bf_emma,sherpa/kokoro:bm_lewis
|
|
||||||
CLINICAL_ASSISTANT_PROMPT_POOL_TARGET: 1000
|
|
||||||
volumes:
|
volumes:
|
||||||
- scribe-logs:/app/data/logs
|
- scribe-logs:/app/data/logs
|
||||||
- clinical-assistant-mcp-data:/app/mcp-data:ro
|
- clinical-assistant-mcp-data:/app/mcp-data:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
container_name: pediatric-ai-scribe
|
container_name: pediatric-ai-scribe
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- danvics_mcp
|
- mcp-server_default
|
||||||
- danvics_monitoring
|
|
||||||
- danvics_speech
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|
@ -46,7 +34,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: pedscribe
|
POSTGRES_DB: pedscribe
|
||||||
POSTGRES_USER: pedscribe
|
POSTGRES_USER: pedscribe
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-pedscribe}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
@ -58,34 +46,13 @@ services:
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:8-alpine
|
|
||||||
command: redis-server --appendonly yes
|
|
||||||
restart: unless-stopped
|
|
||||||
container_name: ped-ai-redis
|
|
||||||
volumes:
|
|
||||||
- redis-data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
- danvics_mcp
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
scribe-logs:
|
scribe-logs:
|
||||||
redis-data:
|
|
||||||
clinical-assistant-mcp-data:
|
clinical-assistant-mcp-data:
|
||||||
external: true
|
external: true
|
||||||
name: mcp-server_mcp-data
|
name: mcp-server_mcp-data
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
danvics_mcp:
|
mcp-server_default:
|
||||||
external: true
|
|
||||||
danvics_monitoring:
|
|
||||||
external: true
|
|
||||||
danvics_speech:
|
|
||||||
external: true
|
external: true
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
# 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
119
docs/SCALING.md
|
|
@ -1,119 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,18 +1,13 @@
|
||||||
# AI providers
|
# AI providers
|
||||||
|
|
||||||
All AI calls flow through `callAI(messages, options)` in `src/utils/ai.js`.
|
All AI calls flow through `callAI(messages, options)` in `src/utils/ai.js`.
|
||||||
Provider is selected at startup and is transparent to route handlers.
|
Provider is selected once at startup and is transparent to callers.
|
||||||
|
|
||||||
## Provider selection
|
## Provider selection
|
||||||
|
|
||||||
1. If `AI_PROVIDER` is set, it chooses `bedrock`, `azure`, `vertex`,
|
1. If `AI_PROVIDER` env var is set, use it.
|
||||||
`litellm`, or `openrouter` explicitly.
|
2. Otherwise, check credentials in priority order:
|
||||||
2. If `AI_PROVIDER` is unset, `ai.js` initializes every configured client and
|
`bedrock > azure > vertex > litellm > openrouter`.
|
||||||
the last configured non-OpenRouter provider wins in current load order:
|
|
||||||
Bedrock → Azure → Vertex → LiteLLM. If none of those are configured,
|
|
||||||
OpenRouter is the default.
|
|
||||||
3. If the selected provider cannot initialize, the code falls back to
|
|
||||||
OpenRouter and surfaces an error if `OPENROUTER_API_KEY` is missing.
|
|
||||||
|
|
||||||
## Providers
|
## Providers
|
||||||
|
|
||||||
|
|
@ -20,7 +15,7 @@ Provider is selected at startup and is transparent to route handlers.
|
||||||
|
|
||||||
- SDK: `@aws-sdk/client-bedrock-runtime`.
|
- SDK: `@aws-sdk/client-bedrock-runtime`.
|
||||||
- Uses Bedrock **inference profiles** for newer models (cross-region routing).
|
- Uses Bedrock **inference profiles** for newer models (cross-region routing).
|
||||||
- Model families: Amazon Nova, Llama (Meta), Mistral, DeepSeek, Cohere, and other Bedrock-hosted families.
|
- Model families: vendor model (Anthropic), Amazon Nova, Llama (Meta), Mistral, DeepSeek, Cohere.
|
||||||
|
|
||||||
### Azure OpenAI (BAA-eligible)
|
### Azure OpenAI (BAA-eligible)
|
||||||
|
|
||||||
|
|
@ -32,7 +27,7 @@ Provider is selected at startup and is transparent to route handlers.
|
||||||
|
|
||||||
- SDK: `@google-cloud/vertexai`.
|
- SDK: `@google-cloud/vertexai`.
|
||||||
- Also serves STT (Gemini inline audio) and TTS (Vertex TTS endpoint).
|
- Also serves STT (Gemini inline audio) and TTS (Vertex TTS endpoint).
|
||||||
- Families: Gemini 2.5 / 2.0 and Llama.
|
- Families: Gemini 2.5 / 2.0, vendor model on Vertex (Anthropic via GCP), Llama.
|
||||||
|
|
||||||
### LiteLLM proxy (self-hosted)
|
### LiteLLM proxy (self-hosted)
|
||||||
|
|
||||||
|
|
@ -129,11 +124,9 @@ Applied to: `soap.js`, `hpi.js`, `refine.js`, `sickVisit.js`, `wellVisit.js`,
|
||||||
|
|
||||||
### Physician memories
|
### Physician memories
|
||||||
|
|
||||||
Saved templates and prompt preferences are injected into prompts as
|
Saved corrections are injected into prompts as `[STYLE HINTS (low priority)]`
|
||||||
`[STYLE HINTS (low priority)]` when they belong to AI-context categories. The
|
with 200-character snippets. The low-priority wording prevents smaller models
|
||||||
low-priority wording prevents smaller models from hallucinating content from a
|
from hallucinating content from the correction examples into the current note.
|
||||||
stored template into the current note. `custom` memories and legacy
|
|
||||||
`correction_*` rows are not prompt context.
|
|
||||||
|
|
||||||
## API call logging
|
## API call logging
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
Self-hosted clinical documentation platform. Dockerized Node.js server, PostgreSQL, Redis, and vanilla-JS SPA. No build step on the frontend.
|
Self-hosted, single-tenant clinical documentation platform. Dockerized Node.js
|
||||||
|
server + PostgreSQL + vanilla-JS SPA. No build step on the frontend.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
|
|
@ -8,11 +9,9 @@ Self-hosted clinical documentation platform. Dockerized Node.js server, PostgreS
|
||||||
|---|---|
|
|---|---|
|
||||||
| Runtime | Node.js 20 (Alpine) + Express 4 |
|
| Runtime | Node.js 20 (Alpine) + Express 4 |
|
||||||
| Database | PostgreSQL 16 with `pgvector` extension |
|
| Database | PostgreSQL 16 with `pgvector` extension |
|
||||||
| Cache / state | Redis for operational cache, prompt suggestions, and queue groundwork |
|
|
||||||
| Frontend | Vanilla JavaScript SPA, service-worker cache |
|
| Frontend | Vanilla JavaScript SPA, service-worker cache |
|
||||||
| Mobile | Capacitor 6 wrapper (Android + iOS) |
|
| Mobile | Capacitor 6 wrapper (Android + iOS) |
|
||||||
| Container | Docker Compose (app + db + Redis) |
|
| Container | Docker Compose (app + db) |
|
||||||
| Observability | Prometheus metrics at `/metrics`; structured app logs in files, Postgres, and optional Loki |
|
|
||||||
| Reverse proxy | External (Caddy, Nginx, Traefik — any) |
|
| Reverse proxy | External (Caddy, Nginx, Traefik — any) |
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
@ -48,9 +47,9 @@ src/
|
||||||
logger.js # audit/api/access + Loki shipper
|
logger.js # audit/api/access + Loki shipper
|
||||||
errors.js # generic 500 responder
|
errors.js # generic 500 responder
|
||||||
models.js, prompts.js, ai.js # AI provider + model + prompt management
|
models.js, prompts.js, ai.js # AI provider + model + prompt management
|
||||||
embeddings.js # LiteLLM embeddings
|
embeddings.js # Vertex / LiteLLM / OpenAI embeddings
|
||||||
transcribe.js, tts.js # LiteLLM STT / TTS routes
|
transcribe*.js, tts*.js # STT / TTS provider clients
|
||||||
routes/ # Express routers (auth, hpi, soap, patient education, …)
|
routes/ # 27 Express routers (auth, hpi, soap, …)
|
||||||
|
|
||||||
public/ # SPA
|
public/ # SPA
|
||||||
index.html # shell, loads components on demand
|
index.html # shell, loads components on demand
|
||||||
|
|
@ -58,19 +57,16 @@ public/ # SPA
|
||||||
js/ # 24 vanilla JS modules
|
js/ # 24 vanilla JS modules
|
||||||
components/ # per-tab HTML fragments
|
components/ # per-tab HTML fragments
|
||||||
css/styles.css
|
css/styles.css
|
||||||
|
models/ # bundled Whisper WASM + model files
|
||||||
|
|
||||||
mobile/ # Capacitor wrapper
|
mobile/ # Capacitor wrapper
|
||||||
capacitor.config.json # appId com.pedshub.scribe
|
capacitor.config.json # appId com.pedshub.scribe
|
||||||
src/ # launcher (server-URL picker)
|
src/ # launcher (server-URL picker)
|
||||||
android/ # generated AS project + native Java
|
android/ # generated AS project + native Java
|
||||||
|
|
||||||
.forgejo/workflows/
|
|
||||||
android-apk.yml # signed APK on tag push; optional Play upload
|
|
||||||
docker-build.yml # Forgejo registry Docker image build
|
|
||||||
|
|
||||||
.github/workflows/
|
.github/workflows/
|
||||||
auto-version.yml # conventional-commits → semver bump → tag
|
auto-version.yml # conventional-commits → semver bump → tag
|
||||||
android-release.yml # legacy GitHub tag APK release path
|
android-release.yml # signed APK on tag push
|
||||||
docker-publish.yml # multi-arch image on tag push
|
docker-publish.yml # multi-arch image on tag push
|
||||||
version-bump.yml # manual dispatch override
|
version-bump.yml # manual dispatch override
|
||||||
build-apk.yml # legacy TWA APK
|
build-apk.yml # legacy TWA APK
|
||||||
|
|
@ -86,7 +82,7 @@ request
|
||||||
→ express.json (10 MB cap)
|
→ express.json (10 MB cap)
|
||||||
→ rate limiters (general 200 req/min, per-endpoint tighter on auth)
|
→ rate limiters (general 200 req/min, per-endpoint tighter on auth)
|
||||||
→ static (public/ with no-cache on HTML, 1h on JS/CSS; ?v=BUILD_ID busts cache per deploy)
|
→ static (public/ with no-cache on HTML, 1h on JS/CSS; ?v=BUILD_ID busts cache per deploy)
|
||||||
→ route (feature routers under /api/*)
|
→ route (27 routers under /api/*)
|
||||||
→ authMiddleware (on protected routes: JWT, DB session check, 24h idle, last_activity update)
|
→ authMiddleware (on protected routes: JWT, DB session check, 24h idle, last_activity update)
|
||||||
→ handler
|
→ handler
|
||||||
→ response
|
→ response
|
||||||
|
|
@ -125,12 +121,6 @@ per-feature HTML fragments under `public/components/` fetched on demand. JS
|
||||||
modules talk via `window` globals and `CustomEvent` on `document` — no
|
modules talk via `window` globals and `CustomEvent` on `document` — no
|
||||||
bundler, no framework. Loader order is fixed in `index.html`.
|
bundler, no framework. Loader order is fixed in `index.html`.
|
||||||
|
|
||||||
Post-note helpers such as billing suggestions, don't-miss review, and patient
|
|
||||||
education handouts are reusable browser-side actions backed by authenticated
|
|
||||||
JSON APIs. The patient education helper generates a parent-facing plain-text
|
|
||||||
draft from the edited note and keeps the clinician in the review loop before
|
|
||||||
copying or sharing.
|
|
||||||
|
|
||||||
`authFetch.js` installs a global `fetch` interceptor that treats any 401 on an
|
`authFetch.js` installs a global `fetch` interceptor that treats any 401 on an
|
||||||
authenticated request as a signal to clear local session state and redirect to
|
authenticated request as a signal to clear local session state and redirect to
|
||||||
login. A `BroadcastChannel('pedscribe-auth')` pushes that signal to sibling
|
login. A `BroadcastChannel('pedscribe-auth')` pushes that signal to sibling
|
||||||
|
|
@ -142,9 +132,8 @@ tabs so logging out in one tab drops UI in every open tab.
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `pediatric-ai-scribe` | `ped-ai-local:latest` (built from repo) | 3000 | 127.0.0.1:3552 |
|
| `pediatric-ai-scribe` | `ped-ai-local:latest` (built from repo) | 3000 | 127.0.0.1:3552 |
|
||||||
| `pedscribe-db` | `pgvector/pgvector:pg16` | 5432 | not exposed |
|
| `pedscribe-db` | `pgvector/pgvector:pg16` | 5432 | not exposed |
|
||||||
| `ped-ai-redis` | Redis | 6379 | not exposed |
|
|
||||||
|
|
||||||
Named volumes: `pgdata` (database), `scribe-logs` (filesystem audit logs), and Redis data if persistence is enabled by compose.
|
Named volumes: `pgdata` (database), `scribe-logs` (filesystem audit logs).
|
||||||
Application health-check polls `GET /api/health`.
|
Application health-check polls `GET /api/health`.
|
||||||
|
|
||||||
A reverse proxy terminates TLS and forwards to `127.0.0.1:3552`. The app is
|
A reverse proxy terminates TLS and forwards to `127.0.0.1:3552`. The app is
|
||||||
|
|
@ -160,11 +149,3 @@ never bound to a public interface directly.
|
||||||
|
|
||||||
Precached on install: `index.html`, core JS, main stylesheet, login component.
|
Precached on install: `index.html`, core JS, main stylesheet, login component.
|
||||||
Cleared on logout (`caches.keys() → caches.delete()`).
|
Cleared on logout (`caches.keys() → caches.delete()`).
|
||||||
|
|
||||||
## Clinical Assistant And MCP
|
|
||||||
|
|
||||||
The clinical assistant can call an external MCP-backed retrieval service. Ped-AI remains responsible for the user workflow, provider selection, prompts, and display. MCP remains responsible for Nextcloud access, indexing, retrieval, and vector search. Clinical answer response caching is intentionally disabled; Redis is used for operational metadata and prompt suggestions, not answer reuse.
|
|
||||||
|
|
||||||
## Speech
|
|
||||||
|
|
||||||
Browser Whisper and browser-local Whisper model downloads are removed from runtime. Speech-to-text routes through LiteLLM; upstream provider choice belongs in LiteLLM config. Browser-native Web Speech remains available only when explicitly enabled by user settings and browser support.
|
|
||||||
|
|
|
||||||
|
|
@ -123,23 +123,9 @@ necessary UX tradeoff over perfect indistinguishability.
|
||||||
|
|
||||||
## Turnstile (Cloudflare bot protection)
|
## Turnstile (Cloudflare bot protection)
|
||||||
|
|
||||||
Applied to `/api/auth/register` and `/api/auth/forgot-password` when
|
Applied to `/api/auth/login`, `/register`, `/forgot-password` when
|
||||||
`TURNSTILE_SECRET_KEY` is set. No-op when unset (dev mode).
|
`TURNSTILE_SECRET_KEY` is set. No-op when unset (dev mode).
|
||||||
|
|
||||||
`/api/auth/login` is deliberately **not** gated: the widget could not
|
|
||||||
reliably complete a challenge inside the Capacitor WebView, which locked
|
|
||||||
mobile users out of the app. Login is covered instead by its per-IP rate
|
|
||||||
limit (10 / 15 min), the constant-time credential check, and TOTP 2FA.
|
|
||||||
|
|
||||||
The two remaining widgets are rendered explicitly (`api.js?render=explicit`)
|
|
||||||
the first time their form becomes visible — Turnstile does not reliably
|
|
||||||
complete a challenge inside a `display:none` container, and both forms start
|
|
||||||
hidden. Tokens are captured from the render callback, not read back out of
|
|
||||||
the injected `[name="cf-turnstile-response"]` input.
|
|
||||||
|
|
||||||
Note that the site key is currently **hardcoded** in `public/index.html`.
|
|
||||||
`TURNSTILE_SITE_KEY` exists in OpenBao but is not read by any code.
|
|
||||||
|
|
||||||
## Encryption at rest
|
## Encryption at rest
|
||||||
|
|
||||||
`src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from
|
`src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from
|
||||||
|
|
@ -160,7 +146,7 @@ Helmet defaults plus:
|
||||||
- `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`
|
- `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`
|
||||||
- Content-Security-Policy:
|
- Content-Security-Policy:
|
||||||
- `script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' cdn.jsdelivr.net cdnjs.cloudflare.com challenges.cloudflare.com`
|
- `script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' cdn.jsdelivr.net cdnjs.cloudflare.com challenges.cloudflare.com`
|
||||||
(do not add `unsafe-eval` unless a reviewed dependency requires it)
|
(`unsafe-eval` is required by @xenova/transformers for in-browser Whisper)
|
||||||
- `script-src-attr 'none'` (blocks inline event handlers)
|
- `script-src-attr 'none'` (blocks inline event handlers)
|
||||||
- `frame-src 'self' challenges.cloudflare.com`
|
- `frame-src 'self' challenges.cloudflare.com`
|
||||||
- `object-src 'none'`
|
- `object-src 'none'`
|
||||||
|
|
|
||||||
174
docs/browser-whisper-setup.md
Normal file
174
docs/browser-whisper-setup.md
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
# Browser Whisper Self-Hosted Setup
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
As of v3, Browser Whisper is **fully self-hosted** with **zero CDN dependencies**. All models and libraries are bundled with the application and served from your own server.
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
**Before (v2 and earlier):**
|
||||||
|
- Loaded transformers.js from `cdn.jsdelivr.net`
|
||||||
|
- Downloaded models from `cdn-lfs.huggingface.co`
|
||||||
|
- Failed in corporate/clinical networks with firewall restrictions
|
||||||
|
|
||||||
|
**Now (v3+):**
|
||||||
|
- Transformers.js library (v2.6.2) bundled at `/models/transformers.min.js` (760KB)
|
||||||
|
- Whisper model bundled at `/models/Xenova/whisper-tiny.en/` (42MB)
|
||||||
|
- Everything served from your own server
|
||||||
|
- **Works in any network environment** (firewalled, air-gapped, offline)
|
||||||
|
|
||||||
|
## Files Included
|
||||||
|
|
||||||
|
```
|
||||||
|
public/models/
|
||||||
|
├── transformers.min.js (760KB) - Transformers.js v2.6.2 (worker-compatible)
|
||||||
|
└── Xenova/
|
||||||
|
└── whisper-tiny.en/ (42MB total)
|
||||||
|
├── config.json
|
||||||
|
├── tokenizer.json
|
||||||
|
├── preprocessor_config.json
|
||||||
|
├── generation_config.json
|
||||||
|
└── onnx/
|
||||||
|
├── encoder_model_quantized.onnx
|
||||||
|
└── decoder_model_merged_quantized.onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Worker loads transformers.js locally:**
|
||||||
|
```javascript
|
||||||
|
importScripts('/models/transformers.min.js');
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Transformers.js configured for local models:**
|
||||||
|
```javascript
|
||||||
|
T.env.localModelPath = '/models/';
|
||||||
|
T.env.allowRemoteModels = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Models load from your server:**
|
||||||
|
- Browser requests: `GET /models/Xenova/whisper-tiny.en/config.json`
|
||||||
|
- Served by Express static middleware
|
||||||
|
- No external network calls
|
||||||
|
|
||||||
|
## Docker Build
|
||||||
|
|
||||||
|
Models are downloaded **during Docker build** (not runtime):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN curl -sL -o onnx/encoder_model_quantized.onnx \
|
||||||
|
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/encoder_model_quantized.onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- Docker image is ~200MB larger (one-time cost)
|
||||||
|
- Runtime has zero dependencies
|
||||||
|
- Works in air-gapped environments (after image is pulled)
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
If you're running locally (not Docker), download models:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd public/models
|
||||||
|
mkdir -p Xenova/whisper-tiny.en/onnx
|
||||||
|
|
||||||
|
# Download transformers.js
|
||||||
|
curl -L -o transformers.min.js \
|
||||||
|
https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js
|
||||||
|
|
||||||
|
# Download model files
|
||||||
|
cd Xenova/whisper-tiny.en
|
||||||
|
curl -L -o config.json \
|
||||||
|
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/config.json
|
||||||
|
curl -L -o tokenizer.json \
|
||||||
|
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/tokenizer.json
|
||||||
|
curl -L -o preprocessor_config.json \
|
||||||
|
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/preprocessor_config.json
|
||||||
|
curl -L -o generation_config.json \
|
||||||
|
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/generation_config.json
|
||||||
|
curl -L -o onnx/encoder_model_quantized.onnx \
|
||||||
|
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/encoder_model_quantized.onnx
|
||||||
|
curl -L -o onnx/decoder_model_merged_quantized.onnx \
|
||||||
|
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/decoder_model_merged_quantized.onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the helper script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/download-whisper-models.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding More Models
|
||||||
|
|
||||||
|
To add base or small models:
|
||||||
|
|
||||||
|
1. **Create directory:**
|
||||||
|
```bash
|
||||||
|
mkdir -p public/models/Xenova/whisper-base.en/onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Download from HuggingFace:**
|
||||||
|
- https://huggingface.co/Xenova/whisper-base.en
|
||||||
|
- https://huggingface.co/Xenova/whisper-small.en
|
||||||
|
|
||||||
|
3. **Update UI in `settings.html`:**
|
||||||
|
```html
|
||||||
|
<option value="Xenova/whisper-base.en">Base (~74MB, better quality)</option>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Update Dockerfile** to download during build
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
✅ **Works everywhere** - No firewall/CDN issues
|
||||||
|
✅ **Privacy-first** - Audio never leaves browser
|
||||||
|
✅ **Offline capable** - After initial page load
|
||||||
|
✅ **No API costs** - Zero transcription expenses
|
||||||
|
✅ **Predictable** - Same model, same results
|
||||||
|
✅ **Fast** - Local processing, no network latency
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Docker image is larger (~200MB vs ~150MB)
|
||||||
|
- Only tiny model included by default (base/small optional)
|
||||||
|
- Slower than cloud APIs for long recordings
|
||||||
|
- Requires modern browser with WebAssembly support
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start server
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 2. Open browser DevTools → Network tab
|
||||||
|
# 3. Go to Settings → Browser Transcription
|
||||||
|
# 4. Click "Pre-download model"
|
||||||
|
# 5. Watch for requests to /models/* (should all be 200 OK from your server)
|
||||||
|
# 6. NO requests to cdn.jsdelivr.net or huggingface.co
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Issue: "Failed to load transformers library"**
|
||||||
|
- Check: `GET /models/transformers.min.js` returns 200 OK
|
||||||
|
- Verify file exists: `ls public/models/transformers.min.js`
|
||||||
|
|
||||||
|
**Issue: "Model load failed"**
|
||||||
|
- Check: `GET /models/Xenova/whisper-tiny.en/config.json` returns 200 OK
|
||||||
|
- Verify files exist: `ls public/models/Xenova/whisper-tiny.en/`
|
||||||
|
|
||||||
|
**Issue: Still seeing CDN requests**
|
||||||
|
- Clear browser cache (Ctrl+Shift+R)
|
||||||
|
- Check you're running v18+ (`/api/health` should show version)
|
||||||
|
|
||||||
|
## Migration from v17
|
||||||
|
|
||||||
|
If upgrading from v17:
|
||||||
|
|
||||||
|
1. Pull new Docker image: `docker-compose pull`
|
||||||
|
2. Restart: `docker-compose up -d`
|
||||||
|
3. Clear browser cache
|
||||||
|
4. Test: Settings → Browser Transcription → Pre-download
|
||||||
|
|
||||||
|
No configuration changes needed - it just works!
|
||||||
240
docs/browser-whisper-troubleshooting.md
Normal file
240
docs/browser-whisper-troubleshooting.md
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
# Browser Whisper Troubleshooting
|
||||||
|
|
||||||
|
## 🎙️ What is Browser Whisper?
|
||||||
|
|
||||||
|
Browser Whisper is an **optional** client-side transcription feature that runs entirely in your browser using WebAssembly. It provides:
|
||||||
|
- ✅ Zero network transmission (HIPAA-safe)
|
||||||
|
- ✅ No API costs
|
||||||
|
- ✅ Works offline
|
||||||
|
- ✅ Privacy-first (audio never leaves device)
|
||||||
|
|
||||||
|
**However**, it requires downloading AI models from CDN servers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Common Issue: CDN Blocked
|
||||||
|
|
||||||
|
### Error Message:
|
||||||
|
```
|
||||||
|
NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope':
|
||||||
|
The script at 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2' failed to load.
|
||||||
|
```
|
||||||
|
|
||||||
|
### What This Means:
|
||||||
|
Your network/firewall is blocking access to:
|
||||||
|
- `cdn.jsdelivr.net` (JavaScript library CDN)
|
||||||
|
- `cdn-lfs.huggingface.co` (AI model files)
|
||||||
|
|
||||||
|
### Why It Happens:
|
||||||
|
1. **Corporate firewall** - Many organizations block CDN domains
|
||||||
|
2. **Browser extensions** - Ad blockers, privacy tools may block CDN
|
||||||
|
3. **Network proxy** - Company proxy might filter JavaScript CDN
|
||||||
|
4. **CSP restrictions** - Very strict Content Security Policy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Solutions
|
||||||
|
|
||||||
|
### Option 1: Use Server Transcription (Recommended)
|
||||||
|
|
||||||
|
**Browser Whisper is optional!** The app works perfectly fine with server-side transcription.
|
||||||
|
|
||||||
|
**Server transcription providers:**
|
||||||
|
- Google Gemini (via Vertex AI) - HIPAA-eligible
|
||||||
|
- AWS Transcribe - HIPAA-eligible
|
||||||
|
- OpenAI Whisper - Fast, accurate
|
||||||
|
- LiteLLM - Routes to any provider
|
||||||
|
|
||||||
|
**To use server transcription:**
|
||||||
|
1. Go to Settings → Browser Transcription
|
||||||
|
2. **Leave it disabled** (or if stuck, disable it)
|
||||||
|
3. Record audio normally - will use server
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- More accurate (larger models)
|
||||||
|
- No download needed
|
||||||
|
- Works immediately
|
||||||
|
- Professional grade
|
||||||
|
|
||||||
|
### Option 2: Whitelist CDN Domains
|
||||||
|
|
||||||
|
If you control your network/firewall, whitelist these domains:
|
||||||
|
|
||||||
|
```
|
||||||
|
cdn.jsdelivr.net
|
||||||
|
cdn-lfs.huggingface.co
|
||||||
|
cdn-lfs-us-1.huggingface.co
|
||||||
|
cdn-lfs-us-2.huggingface.co
|
||||||
|
huggingface.co
|
||||||
|
```
|
||||||
|
|
||||||
|
**For corporate IT:**
|
||||||
|
- These are legitimate AI/JavaScript CDNs
|
||||||
|
- Used by major companies worldwide
|
||||||
|
- No security risk (public CDN content)
|
||||||
|
- Required only for browser-based AI features
|
||||||
|
|
||||||
|
### Option 3: Disable Browser Extensions
|
||||||
|
|
||||||
|
Try disabling:
|
||||||
|
- Ad blockers (uBlock Origin, AdBlock Plus)
|
||||||
|
- Privacy extensions (Privacy Badger, Ghostery)
|
||||||
|
- Script blockers (NoScript, ScriptSafe)
|
||||||
|
|
||||||
|
Then refresh and try again.
|
||||||
|
|
||||||
|
### Option 4: Try Different Browser
|
||||||
|
|
||||||
|
Some browsers have stricter security:
|
||||||
|
- ✅ **Chrome** - Best compatibility
|
||||||
|
- ✅ **Edge** - Works well
|
||||||
|
- ⚠️ **Firefox** - May block CDN
|
||||||
|
- ❌ **Safari** - Limited WebAssembly support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 How to Test If It's Working
|
||||||
|
|
||||||
|
### Test 1: Check CDN Access
|
||||||
|
```bash
|
||||||
|
# From your computer, run:
|
||||||
|
curl -I https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2
|
||||||
|
|
||||||
|
# Should return: HTTP/2 200
|
||||||
|
# If 403 or timeout: CDN is blocked
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 2: Browser Console
|
||||||
|
1. Open DevTools (F12)
|
||||||
|
2. Go to Console tab
|
||||||
|
3. Settings → Browser Transcription
|
||||||
|
4. Click "Pre-download model"
|
||||||
|
5. Watch for:
|
||||||
|
```
|
||||||
|
✅ [WhisperWorker] Transformers library loaded successfully
|
||||||
|
OR
|
||||||
|
❌ NetworkError: Failed to load
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 3: Network Tab
|
||||||
|
1. Open DevTools (F12)
|
||||||
|
2. Go to Network tab
|
||||||
|
3. Click "Pre-download model"
|
||||||
|
4. Look for requests to:
|
||||||
|
- `cdn.jsdelivr.net` (should be 200 OK)
|
||||||
|
- `cdn-lfs.huggingface.co` (should be 200 OK)
|
||||||
|
5. If blocked: Status will show "failed" or "blocked"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 When to Use Each Option
|
||||||
|
|
||||||
|
| Scenario | Recommendation | Why |
|
||||||
|
|----------|---------------|-----|
|
||||||
|
| Corporate network | **Server transcription** | CDN likely blocked |
|
||||||
|
| Home network | **Browser Whisper** | Fast, free, private |
|
||||||
|
| Mobile device | **Server transcription** | Limited storage/memory |
|
||||||
|
| Offline use needed | **Browser Whisper** | Works without internet (after initial download) |
|
||||||
|
| High accuracy needed | **Server transcription** | Larger models available |
|
||||||
|
| Maximum privacy | **Browser Whisper** | Audio never leaves device |
|
||||||
|
| Can't access CDN | **Server transcription** | No choice - CDN blocked |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Technical Details
|
||||||
|
|
||||||
|
### What Gets Downloaded (First Time Only):
|
||||||
|
|
||||||
|
**Tiny model** (~39 MB):
|
||||||
|
- onnx-runtime.wasm (~10 MB)
|
||||||
|
- whisper-tiny.en model files (~29 MB)
|
||||||
|
- Cached in browser IndexedDB (permanent)
|
||||||
|
|
||||||
|
**Base model** (~74 MB):
|
||||||
|
- Larger model, better accuracy
|
||||||
|
|
||||||
|
**Small model** (~244 MB):
|
||||||
|
- Best quality, slower processing
|
||||||
|
|
||||||
|
### Where It's Stored:
|
||||||
|
- **Location:** Browser IndexedDB
|
||||||
|
- **Persistence:** Permanent (until you clear browser data)
|
||||||
|
- **Shared:** Across all tabs/windows for this domain
|
||||||
|
- **Size:** Selected model size (39/74/244 MB)
|
||||||
|
|
||||||
|
### Performance:
|
||||||
|
- **Tiny:** 2-3 seconds per 30-second clip
|
||||||
|
- **Base:** 3-5 seconds per 30-second clip
|
||||||
|
- **Small:** 6-10 seconds per 30-second clip
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ FAQ
|
||||||
|
|
||||||
|
**Q: Is Browser Whisper required?**
|
||||||
|
A: No! It's completely optional. Server transcription works great.
|
||||||
|
|
||||||
|
**Q: Why doesn't it work on my corporate network?**
|
||||||
|
A: Most corporate firewalls block CDN domains for security. Use server transcription instead.
|
||||||
|
|
||||||
|
**Q: Can I download the models manually?**
|
||||||
|
A: Not easily - they're optimized for CDN delivery. Use server transcription if CDN is blocked.
|
||||||
|
|
||||||
|
**Q: Will server transcription cost money?**
|
||||||
|
A: Depends on your provider:
|
||||||
|
- Google Vertex AI: ~$0.005 per minute
|
||||||
|
- AWS Transcribe: ~$0.024 per minute
|
||||||
|
- OpenAI: $0.006 per minute
|
||||||
|
- Very affordable for typical use
|
||||||
|
|
||||||
|
**Q: Is server transcription HIPAA-safe?**
|
||||||
|
A: Yes, if using:
|
||||||
|
- Google Vertex AI (with BAA)
|
||||||
|
- AWS Transcribe (with BAA)
|
||||||
|
- Azure OpenAI (with BAA)
|
||||||
|
|
||||||
|
OpenAI Whisper direct is NOT HIPAA-eligible.
|
||||||
|
|
||||||
|
**Q: Can I use both?**
|
||||||
|
A: Yes! Enable Browser Whisper in Settings. If it fails (CDN blocked), it automatically falls back to server transcription.
|
||||||
|
|
||||||
|
**Q: How do I know which one is being used?**
|
||||||
|
A: Check the toast notification after recording:
|
||||||
|
- "Transcribed locally" = Browser Whisper
|
||||||
|
- "Transcribed via google-gemini/aws/openai" = Server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Recommended Setup
|
||||||
|
|
||||||
|
### For Maximum Privacy (Home Network):
|
||||||
|
1. Enable Browser Whisper
|
||||||
|
2. Choose "Tiny" model (fast, good enough for dictation)
|
||||||
|
3. Pre-download model
|
||||||
|
4. Use offline
|
||||||
|
|
||||||
|
### For Corporate/Clinical Use:
|
||||||
|
1. Keep Browser Whisper **disabled**
|
||||||
|
2. Configure server transcription:
|
||||||
|
```bash
|
||||||
|
# In .env:
|
||||||
|
TRANSCRIBE_PROVIDER=google
|
||||||
|
GOOGLE_VERTEX_PROJECT=your-project
|
||||||
|
```
|
||||||
|
3. Use with BAA for HIPAA compliance
|
||||||
|
|
||||||
|
### For Best Accuracy:
|
||||||
|
1. Use server transcription
|
||||||
|
2. Configure Google Gemini 2.0 Flash or AWS Transcribe Medical
|
||||||
|
3. Audio quality + large models = best results
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Still Having Issues?
|
||||||
|
|
||||||
|
1. **Check console logs:** DevTools → Console → Look for `[BrowserWhisper]` errors
|
||||||
|
2. **Check network logs:** DevTools → Network → Filter by `jsdelivr` or `huggingface`
|
||||||
|
3. **Verify server transcription works:** Just disable Browser Whisper and record
|
||||||
|
4. **Contact IT:** Ask to whitelist CDN domains (if you need Browser Whisper)
|
||||||
|
|
||||||
|
**Remember:** Browser Whisper is a nice-to-have feature. Server transcription is the primary, production-ready method that works everywhere!
|
||||||
|
|
@ -29,33 +29,39 @@ keys):
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `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. |
|
| `AI_PROVIDER` | `openrouter` / `bedrock` / `azure` / `vertex` / `litellm`. Auto-detected by credential presence if unset. |
|
||||||
| `OPENROUTER_API_KEY` | OpenRouter key (not HIPAA-eligible). |
|
| `OPENROUTER_API_KEY` | OpenRouter key (not HIPAA-eligible). |
|
||||||
| `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Bedrock chat provider. |
|
| `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Bedrock / Transcribe / Transcribe-Medical. |
|
||||||
| `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_VERSION` | Azure OpenAI. |
|
| `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_VERSION` | Azure OpenAI. |
|
||||||
| `GOOGLE_VERTEX_PROJECT`, `GOOGLE_VERTEX_LOCATION`, `GOOGLE_APPLICATION_CREDENTIALS` | Vertex AI chat provider. |
|
| `GOOGLE_VERTEX_PROJECT`, `GOOGLE_VERTEX_LOCATION`, `GOOGLE_APPLICATION_CREDENTIALS` | Vertex AI + Gemini (STT/TTS). |
|
||||||
| `LITELLM_API_BASE`, `LITELLM_API_KEY` | OpenAI-compatible AI gateway (Bifrost, LiteLLM, or similar). |
|
| `LITELLM_API_BASE`, `LITELLM_API_KEY` | OpenAI-compatible AI gateway (Bifrost, LiteLLM, or similar). |
|
||||||
|
|
||||||
### Speech-to-text
|
### Speech-to-text
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `TRANSCRIBE_PROVIDER` | Use `litellm`; auto mode uses LiteLLM when configured. |
|
| `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. |
|
||||||
| `LITELLM_STT_MODEL` | Model name for LiteLLM-routed STT. |
|
| `LITELLM_STT_MODEL` | Model name for LiteLLM-routed STT. |
|
||||||
|
|
||||||
### Text-to-speech
|
### Text-to-speech
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `LITELLM_TTS_MODEL`, `LITELLM_TTS_VOICE` | LiteLLM-routed TTS model and default voice. |
|
| `GOOGLE_TTS_VOICE` | Google Cloud TTS voice (e.g. `en-US-Journey-F`). |
|
||||||
| `LITELLM_TTS_VOICES` | Comma-separated LiteLLM-compatible voices exposed in voice search and user preferences. |
|
| `ELEVENLABS_API_KEY` | ElevenLabs (not HIPAA-compliant). |
|
||||||
|
| `LITELLM_TTS_MODEL`, `LITELLM_TTS_VOICE` | LiteLLM-routed TTS. |
|
||||||
|
|
||||||
### Embeddings
|
### Embeddings
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `EMBEDDING_MODEL` | LiteLLM embedding model name (default `openai-text-embedding-3-large`). |
|
| `EMBEDDING_MODEL` | Embedding model name (default `text-embedding-005`, Vertex). |
|
||||||
| `EMBEDDING_DIMENSIONS` | Vector dimensions (default 3072). |
|
| `EMBEDDING_DIMENSIONS` | Vector dimensions (default 768). |
|
||||||
|
|
||||||
### Email (SMTP)
|
### Email (SMTP)
|
||||||
|
|
||||||
|
|
@ -172,8 +178,8 @@ OpenAI-compatible gateway — LiteLLM, Bifrost, or other proxies.
|
||||||
|
|
||||||
3. **Update model names** — Different gateways use different naming
|
3. **Update model names** — Different gateways use different naming
|
||||||
conventions. Bifrost requires `provider/model` format
|
conventions. Bifrost requires `provider/model` format
|
||||||
(e.g., `openrouter/gpt-4.1`), while LiteLLM can use deployment aliases
|
(e.g., `openrouter/vendor-model-sonnet-4.6`), while LiteLLM uses aliases
|
||||||
(e.g., `openrouter-gpt-4.1`). Update model names in:
|
(e.g., `openrouter-vendor-model-sonnet-4.6`). Update model names in:
|
||||||
- Admin Panel → Models (chat models)
|
- Admin Panel → Models (chat models)
|
||||||
- Admin Panel → Settings → `stt.model` (speech-to-text)
|
- Admin Panel → Settings → `stt.model` (speech-to-text)
|
||||||
- Admin Panel → Settings → `tts.model` (text-to-speech)
|
- Admin Panel → Settings → `tts.model` (text-to-speech)
|
||||||
|
|
|
||||||
|
|
@ -138,23 +138,20 @@ Draft/complete encounter workspace. Auto-expires (default 7 d,
|
||||||
|
|
||||||
### `user_memories`
|
### `user_memories`
|
||||||
|
|
||||||
Per-user template and preference rows. Only selected categories are injected
|
Per-user clinical-style hints injected into AI prompts.
|
||||||
into AI generation through `/api/memories/context`; `custom` rows are stored
|
|
||||||
for the user but not included in prompt context.
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
| Column | Type | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| id | SERIAL PK | |
|
| id | SERIAL PK | |
|
||||||
| user_id | INTEGER FK users.id ON DELETE CASCADE | |
|
| user_id | INTEGER FK users.id ON DELETE CASCADE | |
|
||||||
| category | TEXT NOT NULL DEFAULT 'custom' | 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. |
|
| category | TEXT NOT NULL DEFAULT 'custom' | `physical_exam`, `ros`, `encounter_format`, `custom`, `template_*`, `correction_*` |
|
||||||
| name | TEXT NOT NULL | Encrypted with `enc1:` for new rows |
|
| name | TEXT NOT NULL | |
|
||||||
| content | TEXT NOT NULL | Encrypted with `enc1:` for new rows |
|
| content | TEXT NOT NULL | |
|
||||||
| created_at, updated_at | TIMESTAMPTZ DEFAULT NOW() | |
|
| created_at, updated_at | TIMESTAMPTZ DEFAULT NOW() | |
|
||||||
|
|
||||||
### `audio_backups`
|
### `audio_backups`
|
||||||
|
|
||||||
Optional 24-hour encrypted recovery store for recordings when transcription
|
Retry store for failed-transcription audio.
|
||||||
fails, so users can retry without re-recording.
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
| Column | Type | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,8 @@
|
||||||
|
|
||||||
| Image | Role |
|
| Image | Role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `danielonyejesi/pediatric-ai-scribe-v3:latest` | App container. Published by CI on every tag push where configured. Pull directly or build from source. |
|
| `danielonyejesi/pediatric-ai-scribe-v3:latest` | App container. Published by CI on every tag push (multi-arch: `linux/amd64` + `linux/arm64`). Pull directly or build from source. |
|
||||||
| `pgvector/pgvector:pg16` | Database. |
|
| `pgvector/pgvector:pg16` | Database. |
|
||||||
| `redis:7-alpine` | Operational Redis cache/state. |
|
|
||||||
|
|
||||||
## Build from source
|
## Build from source
|
||||||
|
|
||||||
|
|
@ -24,7 +23,8 @@ cp .env.example .env
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The default compose starts `pediatric-ai-scribe` on `127.0.0.1:3552`, `pedscribe-db` internally, and `ped-ai-redis` internally.
|
Two containers come up: `pediatric-ai-scribe` on `127.0.0.1:3552`, `pedscribe-db`
|
||||||
|
internal only.
|
||||||
|
|
||||||
## Minimum `.env`
|
## Minimum `.env`
|
||||||
|
|
||||||
|
|
@ -80,7 +80,7 @@ App sets `trust proxy: 1` so rate limiting uses the original client IP.
|
||||||
| Volume | Contents | Backup priority |
|
| Volume | Contents | Backup priority |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `pgdata` | All user data, encounters, memories, audit logs, settings, embeddings | Critical |
|
| `pgdata` | All user data, encounters, memories, audit logs, settings, embeddings | Critical |
|
||||||
| `scribe-logs` | Filesystem audit log files (JSONL by day) | High for compliance evidence; Postgres also has audit/API/access tables |
|
| `scribe-logs` | Filesystem audit log files (JSONL by day) | Low — Postgres also has these in `audit_log` table |
|
||||||
|
|
||||||
### Postgres backup / restore
|
### Postgres backup / restore
|
||||||
|
|
||||||
|
|
@ -120,7 +120,6 @@ REINDEXes if the ICU library version changed between image builds.
|
||||||
| `GET /api/health` | `{ok:true}` — public, used by Docker health check |
|
| `GET /api/health` | `{ok:true}` — public, used by Docker health check |
|
||||||
| `GET /api/health/detailed` | Provider status — admin-auth required |
|
| `GET /api/health/detailed` | Provider status — admin-auth required |
|
||||||
| `GET /api/build` | Build ID (short git SHA) — useful for debugging cache invalidation |
|
| `GET /api/build` | Build ID (short git SHA) — useful for debugging cache invalidation |
|
||||||
| `GET /metrics` | Prometheus metrics in text exposition format |
|
|
||||||
|
|
||||||
Docker health check in `Dockerfile`: every 30 s, wget-spiders `/api/health`.
|
Docker health check in `Dockerfile`: every 30 s, wget-spiders `/api/health`.
|
||||||
Container marked unhealthy after 5 failures.
|
Container marked unhealthy after 5 failures.
|
||||||
|
|
@ -128,7 +127,7 @@ Container marked unhealthy after 5 failures.
|
||||||
## Resource footprint
|
## Resource footprint
|
||||||
|
|
||||||
- RAM: 256 MB minimum, 512 MB recommended for one instance with a handful of concurrent users.
|
- RAM: 256 MB minimum, 512 MB recommended for one instance with a handful of concurrent users.
|
||||||
- Disk: Postgres size scales with audit log retention, saved encounters, documents, and Learning Hub content.
|
- Disk: ~220 MB image (self-hosted Whisper WASM included). Postgres size scales with audit log retention.
|
||||||
- CPU: idle load negligible; AI calls are network-bound on the LLM provider side.
|
- CPU: idle load negligible; AI calls are network-bound on the LLM provider side.
|
||||||
|
|
||||||
## Production checklist
|
## Production checklist
|
||||||
|
|
@ -142,15 +141,14 @@ Container marked unhealthy after 5 failures.
|
||||||
- Turnstile keys set for public-facing deployments
|
- Turnstile keys set for public-facing deployments
|
||||||
- Reverse proxy serves valid TLS certs
|
- Reverse proxy serves valid TLS certs
|
||||||
- Postgres dump scheduled off-host
|
- Postgres dump scheduled off-host
|
||||||
- Log retention and backup policy covers `audit_log`, `api_log`, `access_log`, and filesystem `scribe-logs`
|
|
||||||
|
|
||||||
## CI / CD
|
## CI / CD
|
||||||
|
|
||||||
On push (and tag push), these workflows run (depending on runner/site):
|
Four workflows fire on tag push:
|
||||||
|
|
||||||
| Workflow | Output | Runtime |
|
| Workflow | Output | Runtime |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `.forgejo/workflows/android-apk.yml` | Signed APK attached to the Forgejo release, plus optional Google Play internal track upload | ~8 min |
|
| `android-release.yml` | Signed APK attached to the GitHub release | ~8 min |
|
||||||
| `docker-publish.yml` | Multi-arch image (amd64 + arm64 via native runners) on Docker Hub | ~4 min |
|
| `docker-publish.yml` | Multi-arch image (amd64 + arm64 via native runners) on Docker Hub | ~4 min |
|
||||||
| `build-apk.yml` | Legacy TWA APK (optional second artifact) | ~2 min |
|
| `build-apk.yml` | Legacy TWA APK (optional second artifact) | ~2 min |
|
||||||
|
|
||||||
|
|
@ -164,7 +162,6 @@ Triggered by `auto-version.yml` (reads commit messages, bumps + tags via
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| App | 3000 | 127.0.0.1:3552 |
|
| App | 3000 | 127.0.0.1:3552 |
|
||||||
| Postgres | 5432 | not exposed |
|
| Postgres | 5432 | not exposed |
|
||||||
| Redis | 6379 | not exposed |
|
|
||||||
|
|
||||||
Change the app's external port by editing the `ports:` mapping in
|
Change the app's external port by editing the `ports:` mapping in
|
||||||
`docker-compose.yml`.
|
`docker-compose.yml`.
|
||||||
|
|
@ -177,8 +174,6 @@ Change the app's external port by editing the `ports:` mapping in
|
||||||
via `src/utils/auditQueue.js`, drained on SIGTERM.
|
via `src/utils/auditQueue.js`, drained on SIGTERM.
|
||||||
4. Loki (if `LOKI_URL` set) — pushed fire-and-forget per event.
|
4. Loki (if `LOKI_URL` set) — pushed fire-and-forget per event.
|
||||||
|
|
||||||
A central Prometheus/Loki/Grafana stack can also scrape `GET /metrics` and collect Docker logs with Promtail. Keep direct Loki push enabled only for structured application events that are useful for compliance and operations.
|
|
||||||
|
|
||||||
## Auto-cleanup
|
## Auto-cleanup
|
||||||
|
|
||||||
| Target | Policy | Frequency |
|
| Target | Policy | Frequency |
|
||||||
|
|
|
||||||
976
docs/developer-guide-extended.md
Normal file
976
docs/developer-guide-extended.md
Normal file
|
|
@ -0,0 +1,976 @@
|
||||||
|
# Pediatric AI Scribe — Developer Guide
|
||||||
|
**Version:** 6.0 | **Stack:** Node.js / Express / PostgreSQL / Vanilla JS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Project Overview](#1-project-overview)
|
||||||
|
2. [Architecture](#2-architecture)
|
||||||
|
3. [Directory Structure](#3-directory-structure)
|
||||||
|
4. [Environment Variables](#4-environment-variables)
|
||||||
|
5. [Database Schema](#5-database-schema)
|
||||||
|
6. [Authentication System](#6-authentication-system)
|
||||||
|
7. [Backend API Reference](#7-backend-api-reference)
|
||||||
|
8. [Frontend Architecture](#8-frontend-architecture)
|
||||||
|
9. [AI Integration](#9-ai-integration)
|
||||||
|
10. [Learning Hub & CMS](#10-learning-hub--cms)
|
||||||
|
11. [Deployment](#11-deployment)
|
||||||
|
12. [Known Issues & Security Notes](#12-known-issues--security-notes)
|
||||||
|
13. [Adding New Features](#13-adding-new-features)
|
||||||
|
14. [Resetting Admin Password via Console](#14-resetting-admin-password-via-console)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Project Overview
|
||||||
|
|
||||||
|
Pediatric AI Scribe is a clinical documentation platform for pediatric healthcare providers. It uses AI (via OpenRouter, AWS Bedrock, or Azure OpenAI) to generate:
|
||||||
|
|
||||||
|
- HPI notes from live encounter recordings
|
||||||
|
- SOAP notes from dictation
|
||||||
|
- Hospital course summaries
|
||||||
|
- Chart reviews
|
||||||
|
- Well-visit notes (including SSHADESS, ROS/PE, milestones)
|
||||||
|
- Sick visit notes
|
||||||
|
- Learning Hub content (articles, quizzes, clinical pearls, presentations)
|
||||||
|
|
||||||
|
**Key design principle:** Single-page application. All tabs are lazy-loaded HTML components (`/public/components/*.html`). JavaScript modules initialize only when their tab is first activated via the `tabChanged` custom event.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser (Vanilla JS + Tiptap)
|
||||||
|
|
|
||||||
|
| HTTP (JWT Bearer token in Authorization header)
|
||||||
|
|
|
||||||
|
Express.js (Node.js) — server.js
|
||||||
|
|
|
||||||
|
|— Helmet (CSP, security headers)
|
||||||
|
|— CORS (restricted to APP_URL in production)
|
||||||
|
|— express-rate-limit (login: 10/15min, register: 5/hr, resend-verify: 3/15min, general: 60/min)
|
||||||
|
|— cookie-parser
|
||||||
|
|— Routes (/src/routes/)
|
||||||
|
|
|
||||||
|
PostgreSQL (pg driver, no ORM)
|
||||||
|
|
|
||||||
|
|— users, app_settings, audit_log, saved_encounters
|
||||||
|
|— user_memories, learning_*, access_log, api_log
|
||||||
|
```
|
||||||
|
|
||||||
|
### How Requests Flow
|
||||||
|
|
||||||
|
1. **Browser** sends HTTP request with `Authorization: Bearer <jwt>` header
|
||||||
|
2. **Express middleware chain:** Helmet (security headers) → CORS → rate limiter → body parser → logging middleware → route handler
|
||||||
|
3. **Auth middleware** (`src/middleware/auth.js`) decodes JWT, queries `users` table, attaches `req.user` with `{ id, email, name, role }`
|
||||||
|
4. **Route handler** processes the request — for AI routes, calls `callAI()` which routes to the configured provider
|
||||||
|
5. **Database** is accessed via the `pg` driver directly (no ORM). All queries use parameterized placeholders (`$1`, `$2`) to prevent SQL injection
|
||||||
|
6. **Response** is JSON for API calls, or static files served from `/public`
|
||||||
|
|
||||||
|
### AI Providers
|
||||||
|
|
||||||
|
Configured via environment variables. The provider is selected at startup in `src/utils/ai.js` using this priority:
|
||||||
|
|
||||||
|
1. **AWS Bedrock** — if `AWS_BEDROCK_REGION` is set. HIPAA eligible with BAA. Uses `@aws-sdk/client-bedrock-runtime`. Anthropic models use the native Messages API (`InvokeModel`); all others use the Converse API.
|
||||||
|
2. **Azure OpenAI** — if `AZURE_OPENAI_ENDPOINT` is set. HIPAA eligible. Uses the OpenAI SDK pointed at your Azure endpoint.
|
||||||
|
3. **OpenRouter** — default fallback if `OPENROUTER_API_KEY` is set. Routes to 20+ models from various providers. Not HIPAA compliant.
|
||||||
|
|
||||||
|
The provider cannot be changed at runtime — it's determined once at startup. To switch providers, update `.env` and restart the container.
|
||||||
|
|
||||||
|
### Database Layer
|
||||||
|
|
||||||
|
The app uses **raw SQL via the `pg` driver** — no ORM (Sequelize, Prisma, etc.). This is intentional:
|
||||||
|
|
||||||
|
- **Simplicity:** Every query is visible and explicit. No magic, no migrations framework, no model definitions to sync.
|
||||||
|
- **Performance:** No ORM overhead or N+1 query problems.
|
||||||
|
- **Schema management:** `src/db/database.js` runs `CREATE TABLE IF NOT EXISTS` on startup, plus `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for migrations. This means the schema is always up-to-date when the app starts.
|
||||||
|
- **Future ORM migration:** If needed, the queries are standard PostgreSQL and can be wrapped by any ORM. The main work would be defining models and replacing direct `db.get()`/`db.run()` calls.
|
||||||
|
|
||||||
|
The `database.js` file exports a helper object (`db`) with convenience methods:
|
||||||
|
- `db.get(sql, params)` — returns first row or `null`
|
||||||
|
- `db.all(sql, params)` — returns all rows as array
|
||||||
|
- `db.run(sql, params)` — executes INSERT/UPDATE/DELETE, returns `{ rowCount }`
|
||||||
|
- `db.getSetting(key)` / `db.setSetting(key, value)` — shorthand for `app_settings` table
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── server.js # Express app entry point (route registration, Helmet CSP,
|
||||||
|
│ # rate limiters, static file serving, error handlers)
|
||||||
|
├── package.json # Dependencies (~25 production deps, no devDeps)
|
||||||
|
├── Dockerfile # Multi-stage Node.js 20 Alpine build
|
||||||
|
├── docker-compose.yml # Production compose (uses Docker Hub image)
|
||||||
|
├── docker-compose.local.yml # Local development (builds from source, port 3552)
|
||||||
|
├── admin-cli.js # CLI tool for admin tasks (create user, reset password)
|
||||||
|
├── DEVELOPER_GUIDE.md # This file
|
||||||
|
│
|
||||||
|
├── src/
|
||||||
|
│ ├── db/
|
||||||
|
│ │ └── database.js # DB connection pool (pg.Pool), schema init
|
||||||
|
│ │ # (CREATE TABLE IF NOT EXISTS for all tables),
|
||||||
|
│ │ # column migrations (ALTER TABLE ADD COLUMN IF NOT EXISTS),
|
||||||
|
│ │ # helper methods: db.get(), db.all(), db.run(),
|
||||||
|
│ │ # db.getSetting(), db.setSetting()
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ ├── auth.js # authMiddleware (JWT decode → req.user),
|
||||||
|
│ │ # adminMiddleware (role === 'admin'),
|
||||||
|
│ │ # moderatorMiddleware (role === 'admin' || 'moderator')
|
||||||
|
│ │ └── logging.js # Logs every request to api_log table (method, path, user, IP, duration)
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── auth.js # Login, register, 2FA, password reset, /me
|
||||||
|
│ │ ├── admin.js # User management (admin only)
|
||||||
|
│ │ ├── adminConfig.js # Site settings, feature flags, AI prompts, models
|
||||||
|
│ │ ├── encounters.js # Save/load/delete draft encounters
|
||||||
|
│ │ ├── memories.js # User templates (physical exam, ROS, etc.)
|
||||||
|
│ │ ├── hpi.js # Generate HPI from encounter/dictation transcript
|
||||||
|
│ │ ├── soap.js # Generate SOAP note
|
||||||
|
│ │ ├── hospitalCourse.js # Generate hospital course summary
|
||||||
|
│ │ ├── chartReview.js # Generate outpatient chart review
|
||||||
|
│ │ ├── milestones.js # Generate developmental milestone narrative
|
||||||
|
│ │ ├── wellVisit.js # Well-visit note generation (ROS/PE/ICD-10)
|
||||||
|
│ │ ├── sickVisit.js # Sick visit note generation
|
||||||
|
│ │ ├── refine.js # Refine/shorten any generated document
|
||||||
|
│ │ ├── transcribe.js # Whisper audio transcription
|
||||||
|
│ │ ├── tts.js # Text-to-speech (if configured)
|
||||||
|
│ │ ├── nextcloud.js # Nextcloud WebDAV connect/export/disconnect
|
||||||
|
│ │ ├── learningHub.js # User-facing: feed, content, quiz submission
|
||||||
|
│ │ ├── learningAdmin.js # CMS: categories, content, questions CRUD
|
||||||
|
│ │ ├── learningAI.js # AI generation for Learning Hub content
|
||||||
|
│ │ └── logs.js # Usage/audit/API/access logs + client error
|
||||||
|
│ └── utils/
|
||||||
|
│ ├── ai.js # callAI(messages, options) — routes to OpenRouter/Bedrock/Azure.
|
||||||
|
│ │ # Handles Anthropic InvokeModel (Messages API) vs Converse API,
|
||||||
|
│ │ # thinking block extraction, fallback model retry, duration tracking.
|
||||||
|
│ ├── models.js # OPENROUTER_MODELS[], BEDROCK_MODELS[], AZURE_MODELS[]
|
||||||
|
│ │ # Each model: { id, name, cost, tag, category, bedrockId, maxOut, regions }
|
||||||
|
│ │ # getBedrockModelId() maps app IDs to Bedrock/inference profile IDs.
|
||||||
|
│ │ # getAvailableModels() filters by region. getAvailableModelsWithOverrides()
|
||||||
|
│ │ # applies admin-disabled/custom models from DB.
|
||||||
|
│ ├── prompts.js # Default prompt templates for every AI route. Loaded on startup,
|
||||||
|
│ │ # then overridden by DB values (app_settings: 'prompt.*' keys).
|
||||||
|
│ │ # PROMPTS.get('key') returns the DB override or default.
|
||||||
|
│ ├── config.js # App configuration helpers
|
||||||
|
│ └── logger.js # Winston logger (file + console, JSON format)
|
||||||
|
│
|
||||||
|
├── public/
|
||||||
|
│ ├── index.html # Single HTML shell, loads all components
|
||||||
|
│ ├── 404.html # Custom 404 page
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── styles.css # All CSS (single file, ~750 lines)
|
||||||
|
│ ├── js/
|
||||||
|
│ │ ├── app.js # Core: tab switching via data-tab buttons, loadComponent()
|
||||||
|
│ │ │ # fetches HTML from /components/, global helpers (showToast,
|
||||||
|
│ │ │ # showLoading, getAuthHeaders, getSelectedModel, etc.)
|
||||||
|
│ │ ├── auth.js # Login/register/forgot-password forms, JWT storage in
|
||||||
|
│ │ │ # localStorage ('ped_scribe_token'), enterApp()/clearSession(),
|
||||||
|
│ │ │ # resend verification link handler, 2FA code input
|
||||||
|
│ │ ├── admin.js # Admin panel: user management, site settings, SMTP config,
|
||||||
|
│ │ │ # model enable/disable, prompt editor, announcement banner
|
||||||
|
│ │ ├── liveEncounter.js # MediaRecorder → Whisper transcription → AI HPI generation.
|
||||||
|
│ │ │ # Handles start/stop recording, timer, save/load encounters
|
||||||
|
│ │ ├── voiceDictation.js # Web Speech API (real-time) or Whisper (recorded) dictation
|
||||||
|
│ │ ├── hospitalCourse.js # Paste/dictate hospital course → AI summary
|
||||||
|
│ │ ├── chartReview.js # Paste/dictate chart data → AI outpatient review
|
||||||
|
│ │ ├── soap.js # Paste/dictate → AI SOAP note
|
||||||
|
│ │ ├── milestones.js # Age-based milestone checklist → AI narrative
|
||||||
|
│ │ ├── wellVisit.js # Well Visit guide: vaccine schedule display, age calculator
|
||||||
|
│ │ ├── shadess.js # SSHADESS psychosocial form + ROS/PE checkboxes → AI note
|
||||||
|
│ │ ├── sickVisit.js # Chief complaint + HPI → AI sick visit SOAP
|
||||||
|
│ │ ├── nextcloud.js # Nextcloud WebDAV connect/disconnect/export settings UI
|
||||||
|
│ │ ├── encounters.js # Save/load/delete encounter drafts (shared across all tabs)
|
||||||
|
│ │ ├── memories.js # User template CRUD (physical exam defaults, ROS, etc.)
|
||||||
|
│ │ ├── learningHub.js # Learning Hub (user feed, content viewer, quiz engine) +
|
||||||
|
│ │ │ # CMS (category CRUD, content editor with Tiptap, question
|
||||||
|
│ │ │ # builder, AI generation panel, Nextcloud file picker,
|
||||||
|
│ │ │ # slide preview modal). Single file, ~1400 lines.
|
||||||
|
│ │ ├── milestonesData.js # Static milestone data by age group (2mo → 6yr)
|
||||||
|
│ │ └── pediatricScheduleData.js # CDC vaccine schedule data + catch-up schedule
|
||||||
|
│ ├── components/ # Lazy-loaded tab HTML (injected by loadComponent)
|
||||||
|
│ │ ├── encounter.html ├── dictation.html ├── hospital.html
|
||||||
|
│ │ ├── chart.html ├── soap.html ├── wellvisit.html
|
||||||
|
│ │ ├── sickvisit.html ├── vaxschedule.html ├── catchup.html
|
||||||
|
│ │ ├── learning.html ├── cms.html ├── admin.html
|
||||||
|
│ │ └── settings.html
|
||||||
|
│ └── vendor/
|
||||||
|
│ └── tiptap.bundle.js # Tiptap 2 + extensions (esbuild bundle, self-hosted)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Environment Variables
|
||||||
|
|
||||||
|
Set in `.env` file (copy `.env.example` to get started):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ── Required ──────────────────────────────────────────────────
|
||||||
|
DATABASE_URL=postgresql://user:pass@host:5432/dbname
|
||||||
|
JWT_SECRET=change-this-to-a-random-64-char-string
|
||||||
|
|
||||||
|
# ── AI Provider (choose one or let it default to OpenRouter) ──
|
||||||
|
OPENROUTER_API_KEY=sk-or-... # Default provider
|
||||||
|
# OR
|
||||||
|
AZURE_OPENAI_ENDPOINT=https://... # Azure (HIPAA eligible)
|
||||||
|
AZURE_OPENAI_API_KEY=...
|
||||||
|
AZURE_DEPLOYMENT_NAME=gpt-4o-mini
|
||||||
|
AZURE_OPENAI_API_VERSION=2024-08-01-preview
|
||||||
|
# OR
|
||||||
|
AWS_BEDROCK_REGION=us-east-1 # AWS Bedrock (HIPAA eligible)
|
||||||
|
AWS_ACCESS_KEY_ID=...
|
||||||
|
AWS_SECRET_ACCESS_KEY=...
|
||||||
|
|
||||||
|
# ── Optional ──────────────────────────────────────────────────
|
||||||
|
OPENAI_API_KEY=sk-... # For Whisper transcription only
|
||||||
|
APP_URL=https://yourdomain.com # Enables secure CORS + Secure cookies
|
||||||
|
NODE_ENV=production # Enables production optimizations
|
||||||
|
PORT=3000 # Default: 3000
|
||||||
|
|
||||||
|
# ── Email (for password reset, registration verification) ──────
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=noreply@example.com
|
||||||
|
SMTP_PASS=...
|
||||||
|
SMTP_FROM=Pediatric AI Scribe <noreply@example.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** If no SMTP is configured, registration auto-verifies and password reset won't work. Configure SMTP or use the console reset method (see Section 14).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Database Schema
|
||||||
|
|
||||||
|
All tables are created automatically on first run by `src/db/database.js`. The file runs `CREATE TABLE IF NOT EXISTS` for every table, followed by `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` migrations for upgrades.
|
||||||
|
|
||||||
|
### Core Tables
|
||||||
|
|
||||||
|
#### `users`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | SERIAL PK | |
|
||||||
|
| email | TEXT UNIQUE | Lowercase |
|
||||||
|
| password | TEXT | bcrypt hash (cost 12) |
|
||||||
|
| name | TEXT | Display name |
|
||||||
|
| role | TEXT | `'user'` \| `'moderator'` \| `'admin'` |
|
||||||
|
| totp_enabled | BOOLEAN | 2FA status |
|
||||||
|
| totp_secret | TEXT | TOTP secret (base32) |
|
||||||
|
| disabled | BOOLEAN | Soft disable |
|
||||||
|
| email_verified | BOOLEAN | |
|
||||||
|
| verify_token / verify_expires | TEXT / BIGINT | Email verification |
|
||||||
|
| reset_token / reset_expires | TEXT / BIGINT | Password reset |
|
||||||
|
| nextcloud_url / nextcloud_user / nextcloud_token / nextcloud_folder | TEXT | Nextcloud integration |
|
||||||
|
| webdav_learning_path | TEXT | Default WebDAV path for Learning Hub file picker |
|
||||||
|
| created_at | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
#### `app_settings`
|
||||||
|
Key-value store for all site configuration. Read via `db.getSetting(key)`, written via admin panel or direct DB.
|
||||||
|
|
||||||
|
Important keys:
|
||||||
|
- `registration_enabled` — `'true'` / `'false'`
|
||||||
|
- `announcement.enabled` / `announcement.text` / `announcement.type`
|
||||||
|
- `smtp.*` — SMTP config (overrides env vars)
|
||||||
|
- `ai.prompt.*` — AI prompt overrides
|
||||||
|
- `model.*` — enabled/disabled models
|
||||||
|
|
||||||
|
#### `saved_encounters`
|
||||||
|
Draft encounters (7-day auto-expiry). Columns: `label`, `enc_type`, `transcript`, `generated_note`, `partial_data` (JSON), `status`, `expires_at`.
|
||||||
|
|
||||||
|
#### `user_memories`
|
||||||
|
User templates fed into AI generation. `category` is one of: `physical_exam`, `ros`, `encounter_format`, `family_history`, `assessment_plan`, `custom`.
|
||||||
|
|
||||||
|
### Learning Hub Tables
|
||||||
|
|
||||||
|
#### `learning_categories`
|
||||||
|
Simple category list with `name`, `slug`, `sort_order`.
|
||||||
|
|
||||||
|
#### `learning_content`
|
||||||
|
Articles, quizzes, pearls, presentations. Key columns: `title`, `slug`, `body` (HTML for articles/pearls/quizzes; Marp markdown for presentations), `content_type` (`article` | `quiz` | `pearl` | `presentation`), `published`, `author_id`.
|
||||||
|
|
||||||
|
#### `learning_questions`
|
||||||
|
Quiz questions linked to `learning_content`. `question_type`: `mcq` | `true_false` | `multi`. `explanation` = general explanation shown after answering.
|
||||||
|
|
||||||
|
#### `learning_options`
|
||||||
|
Answer options for quiz questions. `is_correct: boolean`, `explanation` = shown when this wrong option is chosen.
|
||||||
|
|
||||||
|
#### `learning_progress`
|
||||||
|
Quiz attempt scores per user per content item.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Authentication System
|
||||||
|
|
||||||
|
**Current implementation: JWT in localStorage**
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
1. `POST /api/auth/login` → returns `{ success, token, user }`
|
||||||
|
2. Frontend stores token in `localStorage` as `ped_scribe_token` and in `window.AUTH_TOKEN`
|
||||||
|
3. All API calls include `Authorization: Bearer <token>` header via `getAuthHeaders()`
|
||||||
|
4. `src/middleware/auth.js` validates the Bearer token, attaches `req.user`
|
||||||
|
5. Logout: `clearSession()` removes token from localStorage (client-side only)
|
||||||
|
|
||||||
|
### Token
|
||||||
|
- Signed with `JWT_SECRET` env var
|
||||||
|
- 7-day expiry
|
||||||
|
- Payload: `{ userId: number }`
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
- `user` — standard access (clinical tools only)
|
||||||
|
- `moderator` — can create/edit Learning Hub content
|
||||||
|
- `admin` — full access including user management and site settings
|
||||||
|
|
||||||
|
### Middleware
|
||||||
|
- `authMiddleware` — validates JWT, populates `req.user`
|
||||||
|
- `adminMiddleware` — run after auth, requires `role === 'admin'`
|
||||||
|
- `moderatorMiddleware` — run after auth, requires `role === 'admin' OR 'moderator'`
|
||||||
|
|
||||||
|
### 2FA
|
||||||
|
Uses TOTP (speakeasy). If enabled, login returns `{ requires2FA: true }` and the client must POST the TOTP code to complete login.
|
||||||
|
|
||||||
|
### Session Check on Page Load (auth.js)
|
||||||
|
```javascript
|
||||||
|
var savedToken = localStorage.getItem('ped_scribe_token');
|
||||||
|
if (savedToken) {
|
||||||
|
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + savedToken } })
|
||||||
|
.then(/* if ok → enterApp(), else → clearSession() */);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The `has-session` CSS class on `<html>` hides the auth screen immediately when a localStorage token exists, preventing a white flash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Backend API Reference
|
||||||
|
|
||||||
|
All routes are prefixed `/api`. Routes requiring auth are marked (A). Admin-only: (ADM). Moderator+: (MOD).
|
||||||
|
|
||||||
|
### Auth — `/api/auth/`
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| POST | `/login` | — | Email + password login. Returns `{ token, user }` |
|
||||||
|
| POST | `/register` | — | Create account (checks `registration_enabled` setting) |
|
||||||
|
| GET | `/me` | A | Returns current user object |
|
||||||
|
| POST | `/logout` | — | Clears server-side state (currently no-op, kept for future) |
|
||||||
|
| POST | `/setup-2fa` | A | Generates TOTP secret + QR code |
|
||||||
|
| POST | `/verify-2fa` | A | Confirms TOTP code, enables 2FA |
|
||||||
|
| POST | `/disable-2fa` | A | Disables 2FA (requires password) |
|
||||||
|
| POST | `/forgot-password` | — | Sends reset email |
|
||||||
|
| POST | `/reset-password` | — | Sets new password via reset token |
|
||||||
|
| GET | `/registration-status` | — | Returns `{ registrationEnabled: bool }` |
|
||||||
|
| GET | `/verify-email` | — | Verifies email via token in query string |
|
||||||
|
|
||||||
|
### Clinical — AI Generation
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| POST | `/generate-hpi-encounter` | A | HPI from live encounter transcript |
|
||||||
|
| POST | `/generate-hpi-dictation` | A | HPI from dictation |
|
||||||
|
| POST | `/generate-soap` | A | SOAP note |
|
||||||
|
| POST | `/generate-hospital-course` | A | Hospital course summary |
|
||||||
|
| POST | `/generate-chart-review` | A | Chart review |
|
||||||
|
| POST | `/generate-milestone-narrative` | A | Milestone narrative |
|
||||||
|
| POST | `/generate-milestone-summary` | A | 3-sentence milestone summary |
|
||||||
|
| POST | `/well-visit/note` | A | Full well-visit note |
|
||||||
|
| POST | `/sick-visit/note` | A | Sick visit SOAP |
|
||||||
|
| POST | `/transcribe` | A | Whisper audio → text (multipart/form-data, field: `audio`) |
|
||||||
|
| POST | `/refine` | A | Refine existing document |
|
||||||
|
| POST | `/shorten` | A | Shorten existing document |
|
||||||
|
| POST | `/clarify` | A | Find missing info in a document |
|
||||||
|
|
||||||
|
### Encounters (Save/Load)
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| GET | `/encounters` | A | List user's saved encounters |
|
||||||
|
| POST | `/encounters` | A | Save/update encounter draft |
|
||||||
|
| DELETE | `/encounters/:id` | A | Delete a draft |
|
||||||
|
|
||||||
|
### User Templates
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| GET | `/memories` | A | List user's templates |
|
||||||
|
| POST | `/memories` | A | Create template |
|
||||||
|
| PUT | `/memories/:id` | A | Update template |
|
||||||
|
| DELETE | `/memories/:id` | A | Delete template |
|
||||||
|
| GET | `/memories/context` | A | Returns templates formatted for AI injection |
|
||||||
|
|
||||||
|
### Nextcloud
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| POST | `/nextcloud/connect` | A | Connect + test Nextcloud credentials |
|
||||||
|
| POST | `/nextcloud/export` | A | Export text file to Nextcloud |
|
||||||
|
| POST | `/nextcloud/disconnect` | A | Remove Nextcloud credentials |
|
||||||
|
|
||||||
|
### Learning Hub (User-Facing)
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| GET | `/learning/categories` | A | List categories |
|
||||||
|
| GET | `/learning/feed` | A | Paginated published content |
|
||||||
|
| GET | `/learning/category/:slug` | A | Content by category |
|
||||||
|
| GET | `/learning/content/:slug` | A | Single content item + questions |
|
||||||
|
| POST | `/learning/submit-quiz` | A | Submit quiz answers, returns scored results |
|
||||||
|
| GET | `/learning/search` | A | Full-text search |
|
||||||
|
|
||||||
|
### Learning Hub CMS (Moderator+)
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| GET | `/admin/learning/categories` | MOD | All categories with counts |
|
||||||
|
| POST | `/admin/learning/categories` | MOD | Create category |
|
||||||
|
| PUT | `/admin/learning/categories/:id` | MOD | Update category |
|
||||||
|
| DELETE | `/admin/learning/categories/:id` | MOD | Delete category |
|
||||||
|
| GET | `/admin/learning/content` | MOD | All content (including drafts) |
|
||||||
|
| GET | `/admin/learning/content/:id` | MOD | Single item with questions |
|
||||||
|
| POST | `/admin/learning/content` | MOD | Create content |
|
||||||
|
| PUT | `/admin/learning/content/:id` | MOD | Update content |
|
||||||
|
| DELETE | `/admin/learning/content/:id` | MOD | Delete content + questions |
|
||||||
|
| POST | `/admin/learning/content/:id/questions` | MOD | Add question to content |
|
||||||
|
| PUT | `/admin/learning/questions/:id` | MOD | Update question + options |
|
||||||
|
| DELETE | `/admin/learning/questions/:id` | MOD | Delete question |
|
||||||
|
| GET | `/admin/learning/stats` | MOD | Dashboard stats |
|
||||||
|
|
||||||
|
### Learning Hub AI (Moderator+)
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| POST | `/admin/learning/ai-generate` | MOD | Generate content from topic/file/Nextcloud (multipart/form-data) |
|
||||||
|
| POST | `/admin/learning/ai-refine` | MOD | Refine body HTML with instructions |
|
||||||
|
| POST | `/admin/learning/preview-slides` | MOD | Render Marp markdown → `{ css, slides[] }` for preview |
|
||||||
|
| POST | `/admin/learning/generate-pptx` | MOD | Marp markdown → `.pptx` download (pptxgenjs) |
|
||||||
|
| GET | `/admin/learning/webdav-browse` | MOD | PROPFIND Nextcloud folder |
|
||||||
|
| POST | `/admin/learning/webdav-path` | MOD | Save user's default WebDAV path |
|
||||||
|
|
||||||
|
### Admin (Admin Only)
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| GET | `/admin/users` | ADM | List all users |
|
||||||
|
| POST | `/admin/users` | ADM | Create user |
|
||||||
|
| PUT | `/admin/users/:id` | ADM | Update user (role, disable) |
|
||||||
|
| DELETE | `/admin/users/:id` | ADM | Delete user |
|
||||||
|
| GET/POST | `/admin/config/*` | ADM | Site settings (announcement, SMTP, models, prompts, etc.) |
|
||||||
|
|
||||||
|
### Logs & Health
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| GET | `/health` | — | Returns `{ status: 'running', version, provider }` |
|
||||||
|
| GET | `/models` | — | Returns available AI models list |
|
||||||
|
| POST | `/logs/client-error` | — | Client-side error logging (public) |
|
||||||
|
| GET | `/logs/usage` | ADM | API usage log |
|
||||||
|
| GET | `/logs/audit` | ADM | Audit log |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Frontend Architecture
|
||||||
|
|
||||||
|
### Tab Loading (Lazy Components)
|
||||||
|
Every tab's HTML lives in `/public/components/<tabname>.html`. When a tab button is clicked, `loadComponent()` in `app.js` fetches the HTML, injects it into the tab section, then fires `tabChanged` event.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// app.js
|
||||||
|
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical pattern:** Every JS module that needs to access tab DOM elements MUST listen for `tabChanged`, not `DOMContentLoaded`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Correct pattern for every tab module
|
||||||
|
(function() {
|
||||||
|
var _inited = false;
|
||||||
|
document.addEventListener('tabChanged', function(e) {
|
||||||
|
if (e.detail.tab !== 'myTab' || _inited) return;
|
||||||
|
_inited = true;
|
||||||
|
// Now safe to querySelector elements — they exist in the DOM
|
||||||
|
var btn = document.getElementById('my-btn');
|
||||||
|
btn.addEventListener('click', ...);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
If you use `DOMContentLoaded` instead, the elements won't exist yet (they're loaded async) and you'll get `null.addEventListener` errors.
|
||||||
|
|
||||||
|
### Global Functions (defined in app.js)
|
||||||
|
These are available everywhere — no imports needed:
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `getAuthHeaders()` | Returns `{ 'Content-Type': 'application/json', 'Authorization': 'Bearer <token>' }` |
|
||||||
|
| `getSelectedModel()` | Returns model ID from active tab's selector or global selector |
|
||||||
|
| `showLoading(msg)` | Shows full-screen loading overlay |
|
||||||
|
| `hideLoading()` | Hides loading overlay |
|
||||||
|
| `showToast(msg, type)` | Shows toast notification. `type`: `'success'`\|`'error'`\|`'info'`\|`'warning'` |
|
||||||
|
| `setOutputText(el, text)` | Sets text on contenteditable div, converting `\n` to `<br>` |
|
||||||
|
| `transcribeAudio(blob)` | Sends audio blob to `/api/transcribe`, returns `{ success, text }` |
|
||||||
|
| `createSpeechRecognition()` | Returns Web Speech API recognition instance |
|
||||||
|
| `createTimer(el)` | Returns timer object with `.start()` / `.stop()` |
|
||||||
|
|
||||||
|
### Rich Text Editor (Tiptap)
|
||||||
|
The body editor in the CMS uses Tiptap 2 (headless, no styling framework). The bundle is pre-built at `/public/vendor/tiptap.bundle.js` and exposes `window.Tiptap = { Editor, StarterKit, Link, Underline, TextStyle, Color }`.
|
||||||
|
|
||||||
|
To rebuild the bundle after updating Tiptap packages:
|
||||||
|
```bash
|
||||||
|
cat > tiptap-entry.js << 'EOF'
|
||||||
|
import { Editor } from '@tiptap/core';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import Link from '@tiptap/extension-link';
|
||||||
|
import Underline from '@tiptap/extension-underline';
|
||||||
|
import { TextStyle } from '@tiptap/extension-text-style';
|
||||||
|
import { Color } from '@tiptap/extension-color';
|
||||||
|
window.Tiptap = { Editor, StarterKit, Link, Underline, TextStyle, Color };
|
||||||
|
EOF
|
||||||
|
npx esbuild tiptap-entry.js --bundle --format=iife --minify --outfile=public/vendor/tiptap.bundle.js
|
||||||
|
rm tiptap-entry.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. AI Integration
|
||||||
|
|
||||||
|
### `src/utils/ai.js` — `callAI(messages, options)`
|
||||||
|
|
||||||
|
The single function used by all routes. It routes to the correct provider automatically.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { callAI } = require('../utils/ai');
|
||||||
|
|
||||||
|
const result = await callAI(
|
||||||
|
[{ role: 'user', content: 'Generate a note...' }],
|
||||||
|
{
|
||||||
|
model: 'google/gemini-2.5-flash', // optional, uses default if omitted
|
||||||
|
temperature: 0.3, // optional, default 0.3
|
||||||
|
maxTokens: 4000 // optional, default 4000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// result = { success: true, content: '...', model: '...', provider: '...', duration: ms }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bedrock Model Notes
|
||||||
|
|
||||||
|
**Inference Profiles:** Most newer models (Anthropic vendor model 4.x, Meta Llama 4, DeepSeek R1, Amazon Nova, Writer) require cross-region inference profiles. These use a `us.` prefix on the model ID (e.g. `us.anthropic.agent-config-sonnet-4-6`). Direct model IDs will return "on-demand throughput not supported" errors.
|
||||||
|
|
||||||
|
**Max Output Tokens:** Some models have low output limits (Cohere Command R/R+: 4096, AI21 Jamba: 4096). The `maxOut` field in `models.js` auto-clamps `maxTokens` in `callBedrock()`.
|
||||||
|
|
||||||
|
**JSON Sanitization:** Some models (notably vendor model Sonnet 4.6, Opus 4.6) output literal newline characters inside JSON string values. `learningAI.js` includes a `sanitizeJsonString()` function that escapes these before parsing.
|
||||||
|
|
||||||
|
### Prompt System
|
||||||
|
Prompts are defined in `src/utils/prompts.js`. Admins can override any prompt via the Admin panel (`/admin/config/prompts`). Overrides are stored in `app_settings` table and loaded into memory on startup (with 3s grace period for DB readiness).
|
||||||
|
|
||||||
|
To add a new prompt:
|
||||||
|
1. Add a default in `prompts.js`
|
||||||
|
2. Use `PROMPTS.get('your-prompt-key')` in your route
|
||||||
|
3. The admin panel will auto-discover it
|
||||||
|
|
||||||
|
### AI Generate for Learning Hub
|
||||||
|
The `src/routes/learningAI.js` file handles all Learning Hub AI generation.
|
||||||
|
|
||||||
|
**For presentations:** The AI is prompted to return raw Marp markdown (not JSON). The response is stored in the `body` column. Detection: `content_type === 'presentation'`.
|
||||||
|
|
||||||
|
**For articles/quizzes/pearls:** The AI returns JSON:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "...",
|
||||||
|
"subject": "...",
|
||||||
|
"body": "<p>HTML content</p>",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"question_text": "...",
|
||||||
|
"question_type": "mcq",
|
||||||
|
"explanation": "...",
|
||||||
|
"options": [
|
||||||
|
{ "option_text": "...", "is_correct": true, "explanation": "..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Learning Hub & CMS
|
||||||
|
|
||||||
|
### Content Types
|
||||||
|
| Type | Body format | Has questions |
|
||||||
|
|------|-------------|---------------|
|
||||||
|
| `article` | HTML (Tiptap) | Optional |
|
||||||
|
| `quiz` | HTML (brief intro) | Always |
|
||||||
|
| `pearl` | HTML | Optional |
|
||||||
|
| `presentation` | Marp markdown | Never |
|
||||||
|
|
||||||
|
### Quiz Question Types
|
||||||
|
- `mcq` — Single choice (radio buttons), 4 options, 1 correct
|
||||||
|
- `true_false` — 2 options: "True" / "False", 1 correct
|
||||||
|
- `multi` — Multiple select (checkboxes), scoring: all correct chosen AND no incorrect chosen
|
||||||
|
|
||||||
|
### PPTX Generation
|
||||||
|
`POST /admin/learning/generate-pptx` parses Marp markdown (splits on `---`), extracts `#` headings as slide titles, bullet points as content, and uses `pptxgenjs` to create a real `.pptx`. **No Chromium required** — pure Node.js.
|
||||||
|
|
||||||
|
### Slide Preview
|
||||||
|
`POST /admin/learning/preview-slides` uses `@marp-team/marp-core` to render Marp markdown to HTML, then extracts individual `<section>` elements. Returns `{ css, slides[] }`. The frontend renders these one at a time in a full-screen modal with arrow key + swipe navigation.
|
||||||
|
|
||||||
|
### Content Display
|
||||||
|
In the Learning Hub viewer, content `body` is rendered via `sanitizeHtml()` in `learningHub.js`. This function allows a safe subset of HTML tags only (no `<script>`, no `on*` attributes, no `style` attributes except `class`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Deployment
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # Fill in your credentials
|
||||||
|
docker compose -f docker-compose.local.yml build --no-cache
|
||||||
|
docker compose -f docker-compose.local.yml up -d
|
||||||
|
# App runs at http://localhost:3552
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs & Debugging
|
||||||
|
|
||||||
|
**View container logs (live):**
|
||||||
|
```bash
|
||||||
|
docker logs -f pediatric-ai-scribe
|
||||||
|
```
|
||||||
|
|
||||||
|
**View last N lines:**
|
||||||
|
```bash
|
||||||
|
docker logs --tail 50 pediatric-ai-scribe
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter for specific issues:**
|
||||||
|
```bash
|
||||||
|
# AI/Bedrock errors
|
||||||
|
docker logs pediatric-ai-scribe 2>&1 | grep -i "Bedrock\|LearningAI\|callAI"
|
||||||
|
|
||||||
|
# Auth errors
|
||||||
|
docker logs pediatric-ai-scribe 2>&1 | grep -i "Auth\|login\|verify"
|
||||||
|
|
||||||
|
# All errors
|
||||||
|
docker logs pediatric-ai-scribe 2>&1 | grep -i "error\|ERR\|fail"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key log prefixes:**
|
||||||
|
| Prefix | Source |
|
||||||
|
|--------|--------|
|
||||||
|
| `[Bedrock] Model:` | AI response metadata (block types, stop reason) |
|
||||||
|
| `[LearningAI]` | JSON parse failures with raw output context |
|
||||||
|
| `[Auth]` | Login, registration, verification events |
|
||||||
|
| `[TTS]` | Text-to-speech generation |
|
||||||
|
| `🤖 Provider:` | Startup: which AI provider is active |
|
||||||
|
| `✅ AWS Bedrock:` | Startup: Bedrock configured successfully |
|
||||||
|
|
||||||
|
**Database logs (PostgreSQL):**
|
||||||
|
```bash
|
||||||
|
docker logs pedscribe-db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production (Docker Hub image)
|
||||||
|
```bash
|
||||||
|
# docker-compose.yml (production)
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: danielonyejesi/pediatric-ai-scribe-v3:latest
|
||||||
|
ports: ["3000:3000"]
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: pedscribe
|
||||||
|
POSTGRES_USER: pedscribe
|
||||||
|
POSTGRES_PASSWORD: your_secure_password
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Hub
|
||||||
|
Repository: `danielonyejesi/pediatric-ai-scribe-v3`
|
||||||
|
|
||||||
|
Tags use versioned format: `v5.0`, `v5.1`, etc. Production should always pin to a specific tag.
|
||||||
|
|
||||||
|
### Git Repository
|
||||||
|
Repository: `ifedan-ed/pediatric-ai-scribe-v3` (private)
|
||||||
|
|
||||||
|
### Build & Push Process
|
||||||
|
```bash
|
||||||
|
# 1. Test locally first
|
||||||
|
docker compose -f docker-compose.local.yml build --no-cache
|
||||||
|
docker compose -f docker-compose.local.yml up -d
|
||||||
|
# Test at http://localhost:3552
|
||||||
|
|
||||||
|
# 2. When ready, tag and push to Docker Hub
|
||||||
|
docker tag scribe-pediatric-scribe:latest danielonyejesi/pediatric-ai-scribe-v3:v5.x
|
||||||
|
docker push danielonyejesi/pediatric-ai-scribe-v3:v5.x
|
||||||
|
|
||||||
|
# 3. Update production docker-compose.yml to use new tag
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Known Issues & Security Notes
|
||||||
|
|
||||||
|
### Active Known Issues
|
||||||
|
1. **`nodemailer` HIGH vulnerability** — v6.9.x has an email domain interpretation conflict. Upgrade to `^6.10.0` when available.
|
||||||
|
2. **`unsafe-inline` in CSP** — `scriptSrc` includes `'unsafe-inline'` to support inline event handlers in HTML components. Should migrate to event listeners and remove this directive.
|
||||||
|
3. **JWT in localStorage** — Tokens stored in `localStorage` are readable by JavaScript and therefore vulnerable to XSS attacks. A future migration to `httpOnly` cookies would eliminate this risk. See notes in auth.js and Section 6.
|
||||||
|
4. **`window.prompt()` in `runAiRefineBody`** — Uses browser native prompt, which can be blocked in certain contexts. Should be replaced with an inline input field.
|
||||||
|
5. **`webdav-learning-path` endpoint** — Sits behind `moderatorMiddleware` but is a user preference that non-moderator users might reasonably need. Consider moving to plain `authMiddleware`.
|
||||||
|
|
||||||
|
### Security Hardening Already In Place
|
||||||
|
- Helmet.js with custom CSP (no external script sources)
|
||||||
|
- CORS restricted to `APP_URL` in production
|
||||||
|
- Rate limiting on login (10/15min), register (5/hr), forgot-password (5/hr), resend-verification (3/15min), general API (60/min)
|
||||||
|
- bcrypt cost 12 for password hashing
|
||||||
|
- JWT with 7-day expiry
|
||||||
|
- SQL injection protection: all queries use parameterized `?` / `$1` placeholders
|
||||||
|
- Dynamic table names validated against an explicit allowlist (`ALLOWED_SLUG_TABLES`)
|
||||||
|
- User input in HTML contexts goes through `sanitizeHtml()` (tag allowlist, strips `on*` attributes)
|
||||||
|
- File upload MIME type validated by extension + content type
|
||||||
|
- Admin/moderator route protection via middleware
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Adding New Features
|
||||||
|
|
||||||
|
### Adding a New Clinical Tab
|
||||||
|
1. Create `public/components/mytab.html` with the tab's UI
|
||||||
|
2. Add to `index.html`:
|
||||||
|
- Tab button: `<button class="tab-btn" data-tab="mytab">...</button>`
|
||||||
|
- Tab section: `<section id="mytab-tab" class="tab-content" data-component="mytab"></section>`
|
||||||
|
- Script tag: `<script defer src="/js/myTab.js"></script>`
|
||||||
|
3. Create `public/js/myTab.js`:
|
||||||
|
```javascript
|
||||||
|
(function() {
|
||||||
|
var _inited = false;
|
||||||
|
document.addEventListener('tabChanged', function(e) {
|
||||||
|
if (e.detail.tab !== 'mytab' || _inited) return;
|
||||||
|
_inited = true;
|
||||||
|
// Wire up DOM elements here
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
4. Create `src/routes/myTab.js` with the API route
|
||||||
|
5. Register in `server.js`: `app.use('/api', require('./src/routes/myTab'));`
|
||||||
|
|
||||||
|
### Adding a New AI Prompt
|
||||||
|
1. In `src/utils/prompts.js`, add to the defaults object:
|
||||||
|
```javascript
|
||||||
|
'my-prompt': 'You are a pediatric physician...'
|
||||||
|
```
|
||||||
|
2. In your route: `const prompt = PROMPTS.get('my-prompt') + '\n\n' + userInput`
|
||||||
|
3. The admin panel will show an editor for this prompt automatically.
|
||||||
|
|
||||||
|
### Adding a New Learning Hub Content Type
|
||||||
|
1. Add the new type to the `content_type` selector in `cms.html`
|
||||||
|
2. Handle it in `toggleEditorMode()` in `learningHub.js`
|
||||||
|
3. Add to the type detection in `buildGeneratePrompt()` in `learningAI.js`
|
||||||
|
4. Handle rendering in `learningHub.js` `loadContent()` function
|
||||||
|
5. No DB migration needed — `content_type` is a free-text column
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Resetting Admin Password via Console
|
||||||
|
|
||||||
|
If you lose admin access and have no SMTP for password reset, use the Docker console:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Step 1: Get a shell in the running app container
|
||||||
|
docker exec -it pediatric-ai-scribe sh
|
||||||
|
|
||||||
|
# Step 2: Open Node.js REPL
|
||||||
|
node
|
||||||
|
|
||||||
|
# Step 3: Hash your new password
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const hash = await bcrypt.hash('YourNewPassword123!', 12);
|
||||||
|
console.log(hash);
|
||||||
|
// Copy the hash output
|
||||||
|
|
||||||
|
# Step 4: Exit Node REPL
|
||||||
|
.exit
|
||||||
|
|
||||||
|
# Step 5: Open a DB shell
|
||||||
|
# (Exit app container first, then:)
|
||||||
|
docker exec -it pedscribe-db psql $POSTGRES_USER $POSTGRES_DB
|
||||||
|
|
||||||
|
# Step 6: Update the password (paste the hash)
|
||||||
|
UPDATE users
|
||||||
|
SET password = '$2a$12$...(your-hash-here)...'
|
||||||
|
WHERE email = 'your-admin@email.com';
|
||||||
|
|
||||||
|
# Verify:
|
||||||
|
SELECT email, left(password, 7) as hash_prefix FROM users WHERE email = 'your-admin@email.com';
|
||||||
|
|
||||||
|
# Exit:
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enabling Registration via Console
|
||||||
|
```bash
|
||||||
|
docker exec -it pedscribe-db psql $POSTGRES_USER $POSTGRES_DB
|
||||||
|
UPDATE app_settings SET value = 'true' WHERE key = 'registration_enabled';
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating First Admin User (empty database)
|
||||||
|
The first user to register is automatically made admin. Enable registration, register, then disable registration again.
|
||||||
|
|
||||||
|
Or directly:
|
||||||
|
```bash
|
||||||
|
# In the Node REPL inside the app container:
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const hash = await bcrypt.hash('YourPassword', 12);
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO users (email, password, name, role, email_verified) VALUES ($1, $2, $3, 'admin', true)",
|
||||||
|
['admin@yourdomain.com', hash, 'Admin']
|
||||||
|
);
|
||||||
|
pool.end();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Version History (Recent)
|
||||||
|
|
||||||
|
| Tag | Key changes |
|
||||||
|
|-----|-------------|
|
||||||
|
| v3.19 | Login flash fixed (auth screen hidden by CSS default); presentation quiz option; feed labels corrected |
|
||||||
|
| v3.18 | pdf-parse downgraded to v1.1.1; WebDAV selection UX fixed; topic context on upload/WebDAV tabs; inline refine bar replaces window.prompt(); CSP: removed unsafe-inline (all onclick= converted to data-action delegation); webdav-path moved to /api/user/webdav-path (auth-only) |
|
||||||
|
| v3.17 | AI panel context-aware options fixed (style.display replaces classList — CSS cascade bug); quiz card redesign |
|
||||||
|
| v3.16 | DEVELOPER_GUIDE.md created |
|
||||||
|
| v3.15 | Auth reverted to localStorage tokens; slide preview padding fixed |
|
||||||
|
| v3.14 | AI panel context-aware options (word count, slide count, quiz toggle); delete wording per type |
|
||||||
|
| v3.13 | Delete confirm inline bar CSS bug fixed; slide preview in-page modal (arrow/swipe nav); Marp textarea placeholder |
|
||||||
|
| v3.12 | Delete inline confirm bar; lighter login screen; Presentation type (Marp + pptxgenjs PPTX) |
|
||||||
|
| v3.11 | AI content generation for Learning Hub (topic/file/Nextcloud, pdf-parse, pptxgenjs) |
|
||||||
|
| v3.10 | Custom 404 page; server returns 404 for unknown paths |
|
||||||
|
| v3.8 | Quill replaced with Tiptap 2 (self-hosted bundle, inline link bar) |
|
||||||
|
| v5.0 | Resend verification link on login + rate limit (3/15min) |
|
||||||
|
| v5.1–v5.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.*
|
||||||
|
|
@ -37,10 +37,10 @@ src/
|
||||||
fileType.js magic-byte upload verifier
|
fileType.js magic-byte upload verifier
|
||||||
errors.js generic 500 responder
|
errors.js generic 500 responder
|
||||||
logger.js audit + api + access + Loki shipper
|
logger.js audit + api + access + Loki shipper
|
||||||
embeddings.js LiteLLM embeddings
|
embeddings.js Vertex / LiteLLM / OpenAI embeddings
|
||||||
notify.js ntfy push
|
notify.js ntfy push
|
||||||
transcribe.js, tts.js LiteLLM STT / TTS routes
|
transcribe*.js, tts*.js STT / TTS provider clients
|
||||||
routes/ Express routers for auth, AI workflows, education, logs, and user data
|
routes/ 27 routers
|
||||||
|
|
||||||
public/
|
public/
|
||||||
index.html SPA shell, version-stamped asset refs
|
index.html SPA shell, version-stamped asset refs
|
||||||
|
|
@ -49,7 +49,7 @@ public/
|
||||||
js/ 24 vanilla JS modules (no bundler)
|
js/ 24 vanilla JS modules (no bundler)
|
||||||
components/ per-tab HTML fragments loaded on demand
|
components/ per-tab HTML fragments loaded on demand
|
||||||
css/styles.css
|
css/styles.css
|
||||||
template-guide.md downloadable user template guide
|
models/ bundled Whisper WASM
|
||||||
|
|
||||||
mobile/ Capacitor 6 wrapper (Android + iOS)
|
mobile/ Capacitor 6 wrapper (Android + iOS)
|
||||||
.github/workflows/ CI (auto-version, APK, docker)
|
.github/workflows/ CI (auto-version, APK, docker)
|
||||||
|
|
@ -243,19 +243,24 @@ docker exec -w /app pediatric-ai-scribe npm run migrate:new -- add_my_table
|
||||||
3. Admin-editable automatically through `PUT /api/admin/config` which accepts
|
3. Admin-editable automatically through `PUT /api/admin/config` which accepts
|
||||||
arbitrary keys.
|
arbitrary keys.
|
||||||
|
|
||||||
## Physician Templates And Preferences
|
## Physician memory / correction tracker
|
||||||
|
|
||||||
1. Settings saves user templates/preferences through `/api/memories` into
|
1. On note generation, `trackAIOutput(elementId, text)` captures the original
|
||||||
`user_memories`.
|
output in memory.
|
||||||
2. New rows encrypt `name` and `content` with the shared `enc1:` string format.
|
2. User edits the note in a contenteditable field.
|
||||||
3. `GET /api/memories/context` decrypts rows and returns only AI-context
|
3. On Save, `saveCorrection(elementId, section)` diffs current vs. original.
|
||||||
categories: `physical_exam`, `ros`, `encounter_format`, `family_history`,
|
4. If changed by > 2 words or > 20 characters, `POST /api/memories/correction`
|
||||||
`assessment_plan`, `template_soap`, `template_hpi`, `template_wellvisit`,
|
stores the before/after in `user_memories` with category
|
||||||
`template_sickvisit`, and `template_ed`.
|
`correction_{section}`.
|
||||||
4. `custom` rows remain visible in settings but are not included in prompt
|
5. Next generation: `GET /api/memories/context` fetches the 10 most recent per
|
||||||
context.
|
category and `src/utils/prompts.js` injects them as
|
||||||
5. Legacy `correction_*` rows from the removed correction-learning feature are
|
`[STYLE HINTS (low priority)]` 200-character snippets.
|
||||||
filtered out rather than deleted.
|
|
||||||
|
Tabs with correction capture: Live Encounter, SOAP, Dictation, Sick Visit,
|
||||||
|
Well Visit (Hospital Course and Chart Review save corrections when available
|
||||||
|
but don't always have a trackable single output element).
|
||||||
|
|
||||||
|
Maximum 20 corrections retained per category (oldest deleted).
|
||||||
|
|
||||||
## Route reference
|
## Route reference
|
||||||
|
|
||||||
|
|
@ -272,10 +277,10 @@ docker exec -w /app pediatric-ai-scribe npm run migrate:new -- add_my_table
|
||||||
| `sickVisit.js` | `/api` | Auth | Sick visit |
|
| `sickVisit.js` | `/api` | Auth | Sick visit |
|
||||||
| `milestones.js` | `/api` | Auth | Developmental milestone narratives |
|
| `milestones.js` | `/api` | Auth | Developmental milestone narratives |
|
||||||
| `refine.js` | `/api` | Auth | Refine / shorten / clarify |
|
| `refine.js` | `/api` | Auth | Refine / shorten / clarify |
|
||||||
| `transcribe.js` | `/api` | Auth | LiteLLM STT |
|
| `transcribe.js` | `/api` | Auth | STT (5 providers) |
|
||||||
| `tts.js` | `/api` | Auth | LiteLLM TTS |
|
| `tts.js` | `/api` | Auth | TTS (3 providers) |
|
||||||
| `encounters.js` | `/api` | Auth | Save / load / optimistic-lock encounters |
|
| `encounters.js` | `/api` | Auth | Save / load / optimistic-lock encounters |
|
||||||
| `memories.js` | `/api` | Auth | Templates + prompt preferences |
|
| `memories.js` | `/api` | Auth | Templates + corrections |
|
||||||
| `audioBackups.js` | `/api` | Auth | Encrypted audio retry store |
|
| `audioBackups.js` | `/api` | Auth | Encrypted audio retry store |
|
||||||
| `documents.js` | `/api` | Auth | S3 documents (magic-byte checked) |
|
| `documents.js` | `/api` | Auth | S3 documents (magic-byte checked) |
|
||||||
| `userPreferences.js` | `/api` | Auth | Per-user STT/TTS choice |
|
| `userPreferences.js` | `/api` | Auth | Per-user STT/TTS choice |
|
||||||
|
|
@ -302,8 +307,10 @@ docker exec -w /app pediatric-ai-scribe npm run migrate:new -- add_my_table
|
||||||
| `milestones.js` + `milestonesData.js` | Milestones tab |
|
| `milestones.js` + `milestonesData.js` | Milestones tab |
|
||||||
| `shadess.js` | SSHADESS adolescent assessment |
|
| `shadess.js` | SSHADESS adolescent assessment |
|
||||||
| `encounters.js` | Save / load / resume with optimistic lock |
|
| `encounters.js` | Save / load / resume with optimistic lock |
|
||||||
| `memories.js` | Physician templates and prompt preferences UI |
|
| `memories.js` | Physician templates + corrections UI |
|
||||||
| `speechRecognition.js` | Explicit opt-in browser Web Speech support |
|
| `correctionTracker.js` | Captures AI-output edits |
|
||||||
|
| `browserWhisper.js` | In-browser WASM Whisper |
|
||||||
|
| `speechRecognition.js` | Web Speech API preview |
|
||||||
| `voicePreferences.js` | Per-user STT/TTS override |
|
| `voicePreferences.js` | Per-user STT/TTS override |
|
||||||
| `audioBackup.js` | Server + IndexedDB backup retries |
|
| `audioBackup.js` | Server + IndexedDB backup retries |
|
||||||
| `nextcloud.js` | Connect / export |
|
| `nextcloud.js` | Connect / export |
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Embeddings And Semantic Search Setup
|
# Embeddings & Semantic Search Setup
|
||||||
|
|
||||||
This guide explains how to set up and use the new vector-based semantic search for the Learning Hub.
|
This guide explains how to set up and use the new vector-based semantic search for the Learning Hub.
|
||||||
|
|
||||||
## What This Enables
|
## 🎯 What's New
|
||||||
|
|
||||||
- **Semantic search** - Find content by meaning, not just keywords
|
- **Semantic search** - Find content by meaning, not just keywords
|
||||||
- **3 search modes**:
|
- **3 search modes**:
|
||||||
|
|
@ -10,9 +10,9 @@ This guide explains how to set up and use the new vector-based semantic search f
|
||||||
- **Semantic** (`/api/learning/search/semantic`) - AI-powered vector similarity
|
- **Semantic** (`/api/learning/search/semantic`) - AI-powered vector similarity
|
||||||
- **Hybrid** (`/api/learning/search/hybrid`) - Combines both for best results
|
- **Hybrid** (`/api/learning/search/hybrid`) - Combines both for best results
|
||||||
- **Auto-embedding** - Content is automatically vectorized when created/updated
|
- **Auto-embedding** - Content is automatically vectorized when created/updated
|
||||||
- **Gateway-routed** - Uses LiteLLM embeddings so provider policy stays in one place
|
- **HIPAA-compliant** - Uses Vertex AI embeddings (BAA available)
|
||||||
|
|
||||||
## Prerequisites
|
## 📋 Prerequisites
|
||||||
|
|
||||||
### 1. Install pgvector Extension
|
### 1. Install pgvector Extension
|
||||||
|
|
||||||
|
|
@ -37,24 +37,39 @@ postgres:
|
||||||
# ... rest of your config
|
# ... rest of your config
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configure LiteLLM Embeddings
|
### 2. Configure Embedding Provider
|
||||||
|
|
||||||
Add to your `.env` file:
|
Add to your `.env` file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Option 1: Vertex AI (HIPAA-eligible, recommended)
|
||||||
|
EMBEDDING_MODEL=vertex_ai/text-embedding-005
|
||||||
|
EMBEDDING_DIMENSIONS=768
|
||||||
|
VERTEX_PROJECT=your-gcp-project-id
|
||||||
|
VERTEX_LOCATION=us-central1
|
||||||
|
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
|
||||||
|
|
||||||
|
# Option 2: LiteLLM Proxy (routes to any provider)
|
||||||
LITELLM_API_BASE=http://localhost:4000
|
LITELLM_API_BASE=http://localhost:4000
|
||||||
LITELLM_API_KEY=your-key
|
LITELLM_API_KEY=your-key
|
||||||
EMBEDDING_MODEL=openai-text-embedding-3-large
|
EMBEDDING_MODEL=text-embedding-005 # LiteLLM will route to configured provider
|
||||||
EMBEDDING_DIMENSIONS=3072
|
|
||||||
|
# Option 3: OpenAI (NOT HIPAA-eligible, fallback only)
|
||||||
|
OPENAI_API_KEY=sk-your-key
|
||||||
|
# Uses text-embedding-3-small automatically
|
||||||
```
|
```
|
||||||
|
|
||||||
## Available Embedding Models
|
## 🚀 Available Vertex AI Embedding Models
|
||||||
|
|
||||||
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.
|
Tested and working via LiteLLM:
|
||||||
|
|
||||||
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.
|
| 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 |
|
||||||
|
|
||||||
## Setup Steps
|
## 🔧 Setup Steps
|
||||||
|
|
||||||
### 1. Database Migration
|
### 1. Database Migration
|
||||||
|
|
||||||
|
|
@ -98,12 +113,12 @@ Response:
|
||||||
"total": 50,
|
"total": 50,
|
||||||
"withEmbeddings": 50,
|
"withEmbeddings": 50,
|
||||||
"missing": 0,
|
"missing": 0,
|
||||||
"model": "openai-text-embedding-3-large",
|
"model": "vertex_ai/text-embedding-005",
|
||||||
"dimensions": 3072
|
"dimensions": 768
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Using Semantic Search
|
## 🔍 Using Semantic Search
|
||||||
|
|
||||||
### Keyword Search (existing)
|
### Keyword Search (existing)
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -129,12 +144,12 @@ GET /api/learning/search/hybrid?q=fever management
|
||||||
```
|
```
|
||||||
Combines keyword + semantic for best results. Automatically deduplicates and ranks by relevance.
|
Combines keyword + semantic for best results. Automatically deduplicates and ranks by relevance.
|
||||||
|
|
||||||
## How It Works
|
## 🔬 How It Works
|
||||||
|
|
||||||
1. **Content Creation/Update**:
|
1. **Content Creation/Update**:
|
||||||
- Text is extracted from `title`, `subject`, and `body` (HTML stripped)
|
- Text is extracted from `title`, `subject`, and `body` (HTML stripped)
|
||||||
- Sent to the configured LiteLLM embedding model
|
- Sent to embedding model (Vertex AI)
|
||||||
- Returns an embedding vector
|
- Returns 768-dimensional vector
|
||||||
- Stored in `learning_content.embedding` column
|
- Stored in `learning_content.embedding` column
|
||||||
|
|
||||||
2. **Semantic Search**:
|
2. **Semantic Search**:
|
||||||
|
|
@ -149,23 +164,35 @@ Combines keyword + semantic for best results. Automatically deduplicates and ran
|
||||||
- Deduplicates by content ID
|
- Deduplicates by content ID
|
||||||
- Sorts by relevance score
|
- Sorts by relevance score
|
||||||
|
|
||||||
## Cost Estimate
|
## 💰 Cost Estimate (Vertex AI)
|
||||||
|
|
||||||
Embedding cost depends on the upstream configured in LiteLLM.
|
**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
|
||||||
|
|
||||||
## Troubleshooting
|
**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
|
||||||
|
|
||||||
### "pgvector extension not available"
|
### "pgvector extension not available"
|
||||||
- Install: `apt-get install postgresql-16-pgvector`
|
- Install: `apt-get install postgresql-16-pgvector`
|
||||||
- For Docker: Use `pgvector/pgvector:pg16` image
|
- For Docker: Use `pgvector/pgvector:pg16` image
|
||||||
|
|
||||||
### "Embeddings not configured"
|
### "Embeddings not configured"
|
||||||
- Verify `.env` has `LITELLM_API_BASE`
|
- Verify `.env` has `VERTEX_PROJECT` or `LITELLM_API_BASE` or `OPENAI_API_KEY`
|
||||||
|
- Check service account credentials: `GOOGLE_APPLICATION_CREDENTIALS`
|
||||||
- Test: `curl http://localhost:3000/api/admin/learning/embeddings/status`
|
- Test: `curl http://localhost:3000/api/admin/learning/embeddings/status`
|
||||||
|
|
||||||
### "Embedding generation failed"
|
### "Embedding generation failed"
|
||||||
- Check logs for API errors
|
- Check logs for API errors
|
||||||
- Verify LiteLLM `/model/info` shows the selected model with `mode: embedding`
|
- Verify Vertex AI API is enabled in GCP
|
||||||
|
- Verify service account has `aiplatform.endpoints.predict` permission
|
||||||
- Check content isn't empty (skips empty bodies)
|
- Check content isn't empty (skips empty bodies)
|
||||||
|
|
||||||
### "No results from semantic search"
|
### "No results from semantic search"
|
||||||
|
|
@ -173,23 +200,23 @@ Embedding cost depends on the upstream configured in LiteLLM.
|
||||||
- Lower threshold: `?threshold=0.3` (default 0.5)
|
- Lower threshold: `?threshold=0.3` (default 0.5)
|
||||||
- Verify pgvector index exists: `\di` in psql
|
- Verify pgvector index exists: `\di` in psql
|
||||||
|
|
||||||
## Performance
|
## 📊 Performance
|
||||||
|
|
||||||
- **Embedding generation**: latency depends on the LiteLLM upstream
|
- **Embedding generation**: ~500ms per article (Vertex AI)
|
||||||
- **Search latency**:
|
- **Search latency**:
|
||||||
- Keyword: 10-50ms
|
- Keyword: 10-50ms
|
||||||
- Semantic: 20-100ms (with IVFFLAT index)
|
- Semantic: 20-100ms (with IVFFLAT index)
|
||||||
- Hybrid: 30-150ms
|
- Hybrid: 30-150ms
|
||||||
- **Index build time**: ~1-5 seconds per 1,000 articles
|
- **Index build time**: ~1-5 seconds per 1,000 articles
|
||||||
|
|
||||||
## Security And Compliance
|
## 🔐 Security & Compliance
|
||||||
|
|
||||||
- **Compliance**: controlled by the upstream provider configured in LiteLLM
|
- **HIPAA-eligible**: Vertex AI supports BAA (Business Associate Agreement)
|
||||||
- **Data retention**: Embeddings stored in your database only
|
- **Data retention**: Embeddings stored in your database only
|
||||||
- **No PHI**: Only article content (not patient data) is embedded
|
- **No PHI**: Only article content (not patient data) is embedded
|
||||||
- **Encryption**: TLS in transit, at-rest encryption via PostgreSQL
|
- **Encryption**: TLS in transit, at-rest encryption via PostgreSQL
|
||||||
|
|
||||||
## Example Queries
|
## 🎓 Example Queries
|
||||||
|
|
||||||
**Before (keyword):**
|
**Before (keyword):**
|
||||||
```
|
```
|
||||||
|
|
@ -217,7 +244,7 @@ Results:
|
||||||
- Bronchiolitis vs asthma (keyword: 1.0)
|
- Bronchiolitis vs asthma (keyword: 1.0)
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Reference
|
## 📚 API Reference
|
||||||
|
|
||||||
### Admin Endpoints
|
### Admin Endpoints
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,347 @@
|
||||||
# Features Explained
|
# Features Explained - Pediatric AI Scribe v14
|
||||||
|
|
||||||
This file is a practical operator-oriented overview of major Ped-AI features. It intentionally describes the current fork, not historical browser Whisper behavior.
|
## 🎙️ **Audio Backups**
|
||||||
|
|
||||||
## Clinical Documentation
|
### How It Works:
|
||||||
|
Audio backups happen **automatically every time you record**, regardless of transcription success/failure.
|
||||||
|
|
||||||
Ped-AI generates pediatric clinical notes from typed input, dictation, or recorded audio. Major workflows include live encounters, dictation cleanup, sick visits, well visits, SOAP notes, hospital courses, chart review, ED documentation, and developmental milestones.
|
**Flow:**
|
||||||
|
1. You press "Stop" on recording
|
||||||
|
2. Audio is immediately saved **before** transcription starts
|
||||||
|
3. Server-side backup (PostgreSQL, gzip compressed) attempted first
|
||||||
|
4. If server fails → fallback to browser IndexedDB
|
||||||
|
5. After successful transcription → audio backup is deleted
|
||||||
|
6. If transcription fails → audio backup remains for retry
|
||||||
|
|
||||||
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.
|
**Location:**
|
||||||
|
- Server: PostgreSQL `audio_backups` table (auto-deleted after 24 hours)
|
||||||
|
- Browser: IndexedDB `PedScribeAudioBackup` database (manual cleanup)
|
||||||
|
|
||||||
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.
|
**Purpose:**
|
||||||
|
- Retry transcription if it fails
|
||||||
|
- Recover audio if browser crashes
|
||||||
|
- Audit trail (24 hour retention)
|
||||||
|
|
||||||
## Phone Extensions And Pagers
|
**Access:**
|
||||||
|
Settings → Audio Backups section shows:
|
||||||
|
- Date/time of recording
|
||||||
|
- Module (encounter, dictation, etc.)
|
||||||
|
- File size
|
||||||
|
- "Retry Transcription" button (if transcription failed)
|
||||||
|
- "Delete" button
|
||||||
|
|
||||||
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.
|
**Cost:**
|
||||||
|
Server backups are compressed (gzip) to ~1/10 original size. A 2MB recording becomes ~200KB in database.
|
||||||
|
|
||||||
## Speech
|
---
|
||||||
|
|
||||||
Final transcription is server-side through LiteLLM. Configure upstream STT providers in LiteLLM rather than in Ped-AI.
|
## 🌐 **S3 Document Storage**
|
||||||
|
|
||||||
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.
|
### How It Works:
|
||||||
|
Upload documents (PDFs, images, Word docs, text files) to S3-compatible storage.
|
||||||
|
|
||||||
Browser Whisper and browser-local model workers are removed. Do not expect a pre-download model button, public Whisper worker, or bundled Xenova model path.
|
**Supported Providers:**
|
||||||
|
- AWS S3 (default)
|
||||||
|
- Backblaze B2
|
||||||
|
- MinIO (self-hosted)
|
||||||
|
- Any S3-compatible service
|
||||||
|
|
||||||
## Text To Speech
|
**Configuration (.env):**
|
||||||
|
```bash
|
||||||
|
# AWS S3 (uses Bedrock credentials if available)
|
||||||
|
S3_BUCKET=your-bucket-name
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_PREFIX=documents/ # Optional: folder prefix
|
||||||
|
|
||||||
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.
|
# Backblaze B2
|
||||||
|
S3_BUCKET=your-bucket-name
|
||||||
|
S3_ENDPOINT=https://s3.us-west-004.backblazeb2.com
|
||||||
|
S3_REGION=us-west-004
|
||||||
|
S3_ACCESS_KEY_ID=your-b2-application-key-id
|
||||||
|
S3_SECRET_ACCESS_KEY=your-b2-application-key
|
||||||
|
|
||||||
## Learning Hub
|
# MinIO (self-hosted)
|
||||||
|
S3_BUCKET=your-bucket
|
||||||
|
S3_ENDPOINT=http://minio:9000
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_ACCESS_KEY_ID=minio-access-key
|
||||||
|
S3_SECRET_ACCESS_KEY=minio-secret-key
|
||||||
|
S3_FORCE_PATH_STYLE=true # Required for MinIO
|
||||||
|
```
|
||||||
|
|
||||||
Learning Hub is both a learner-facing content area and an admin/moderator CMS.
|
**Features:**
|
||||||
|
- ✅ 10 MB file size limit
|
||||||
|
- ✅ AES-256 server-side encryption
|
||||||
|
- ✅ Per-user folder organization (`documents/{userId}/{uuid}/filename`)
|
||||||
|
- ✅ Metadata stored in PostgreSQL (filename, mime type, size, description)
|
||||||
|
- ✅ Presigned URLs for secure access (1 hour expiry)
|
||||||
|
|
||||||
- Articles and pearls render sanitized content.
|
**Allowed File Types:**
|
||||||
- Quizzes support single-answer, multi-select, and true/false questions.
|
- PDF (`.pdf`)
|
||||||
- Presentations use Marp-style markdown with preview and PPTX export.
|
- Images (`.jpg`, `.jpeg`, `.png`, `.gif`)
|
||||||
- AI generation can use topic text, uploaded source files, or connected Nextcloud WebDAV files.
|
- Word documents (`.doc`, `.docx`)
|
||||||
- Categories can organize content without deleting the content when category assignments change.
|
- Text files (`.txt`, `.csv`)
|
||||||
|
|
||||||
## Nextcloud WebDAV
|
**Access:**
|
||||||
|
Settings → Documents section
|
||||||
|
|
||||||
Users can connect a Nextcloud account with an app password. Learning Hub AI generation can browse files from the connected WebDAV account, and users can set a default browse path to avoid repeatedly navigating to the same clinical content folder.
|
**Status Check:**
|
||||||
|
If S3 is not configured, the Documents section shows empty with message: "S3 not configured"
|
||||||
|
|
||||||
## Documents And S3
|
---
|
||||||
|
|
||||||
Document upload is optional and depends on S3-compatible storage configuration. Treat uploaded documents as PHI unless you have a separate deployment reason not to.
|
## 📚 **Learning Hub - Default Browse Path**
|
||||||
|
|
||||||
## Audio Backups
|
### What It Is:
|
||||||
|
A user preference that sets the **starting folder** when browsing Nextcloud files for AI content generation.
|
||||||
|
|
||||||
Audio backups exist to recover failed transcription attempts.
|
### When It's Used:
|
||||||
|
Only in the **Learning Hub AI Content Generator** (Admin/Moderator feature).
|
||||||
|
|
||||||
- They are created when transcription fails.
|
**Scenario:**
|
||||||
- They are encrypted before persistent storage.
|
1. Admin/Moderator wants to create AI-generated learning content
|
||||||
- They expire automatically.
|
2. They choose "Upload from Nextcloud"
|
||||||
- Users can retry or delete them from Settings.
|
3. File browser opens
|
||||||
|
4. Instead of starting at root `/`, it opens at the configured path
|
||||||
|
|
||||||
## Admin Panel
|
**Example:**
|
||||||
|
```
|
||||||
|
Default path: /Medical-Resources
|
||||||
|
↓
|
||||||
|
When you click "Browse Nextcloud", it opens:
|
||||||
|
/Medical-Resources/
|
||||||
|
├── Pediatric-Guidelines/
|
||||||
|
├── Clinical-Protocols/
|
||||||
|
└── Research-Papers/
|
||||||
|
|
||||||
Admins can manage users, roles, registration, security settings, model defaults, prompts, logs, and Learning Hub content. Production deployments should enable SSO/2FA and restrict admin access.
|
Instead of:
|
||||||
|
/
|
||||||
|
├── Personal/
|
||||||
|
├── Photos/
|
||||||
|
├── Medical-Resources/ ← you'd have to navigate here every time
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
## Feature Status
|
**Configuration:**
|
||||||
|
Settings → Nextcloud Integration → "Learning Hub — Default Browse Path"
|
||||||
|
|
||||||
| Feature | Status | Notes |
|
**Examples:**
|
||||||
|---|---|---|
|
- `/Medical-Resources` - Opens in Medical Resources folder
|
||||||
| Clinical note generation | Active | Provider depends on `AI_PROVIDER`. |
|
- `/Shared/Clinical-Content` - Opens in shared clinical content
|
||||||
| Server transcription | Active | Google/AWS/LiteLLM/OpenAI paths. |
|
- `/` (empty) - Opens at root (default behavior)
|
||||||
| Browser Web Speech preview | Optional | Explicit opt-in only. |
|
|
||||||
| Browser Whisper | Removed | No public worker or model download path. |
|
|
||||||
| Learning Hub CMS | Active | Articles, pearls, quizzes, presentations. |
|
|
||||||
| Nextcloud WebDAV | Active | Used for file browsing/content import. |
|
|
||||||
| Patient handouts | Active | Parent-facing, note-derived, preferred-language draft. |
|
|
||||||
| Extension transfer | Active | ZIP export plus JSON/ZIP import preview. |
|
|
||||||
| Audio backups | Active | Failure recovery only. |
|
|
||||||
| TTS preview | Active | Depends on configured provider. |
|
|
||||||
|
|
||||||
## Troubleshooting
|
**Who Can Use This:**
|
||||||
|
- Any authenticated user (not just moderators)
|
||||||
|
- It's a personal preference per user
|
||||||
|
- Only affects Learning Hub AI file picker
|
||||||
|
|
||||||
- Check browser console for frontend errors.
|
**Why This Exists:**
|
||||||
- Check `docker logs pediatric-ai-scribe -f` for backend errors.
|
If you store learning resources in a specific Nextcloud folder, you don't want to navigate there every single time you generate content. Set it once, it remembers.
|
||||||
- Check `/api/health` for service status.
|
|
||||||
- Check provider credentials and model names before debugging UI state.
|
---
|
||||||
- For Learning Hub file import failures, verify Nextcloud URL, username, app password, and folder path.
|
|
||||||
|
## 🎤 **Browser Whisper Pre-Download**
|
||||||
|
|
||||||
|
### Issue You Reported:
|
||||||
|
"Pre-download models works, stuck at starting download"
|
||||||
|
|
||||||
|
### What's Happening:
|
||||||
|
The download **is actually working** but progress updates are slow because:
|
||||||
|
1. HuggingFace CDN serves large files (39-244 MB)
|
||||||
|
2. Progress callbacks are not granular (reported per-file, not per-chunk)
|
||||||
|
3. Initial ONNX runtime download has no progress tracking
|
||||||
|
|
||||||
|
### Fixed:
|
||||||
|
- ✅ Added console logging to track progress
|
||||||
|
- ✅ Added 30-second timeout warning (doesn't stop download)
|
||||||
|
- ✅ Better error messages
|
||||||
|
|
||||||
|
### How to Test:
|
||||||
|
1. Open browser DevTools (F12) → Console tab
|
||||||
|
2. Click "Pre-download model"
|
||||||
|
3. Watch console for progress logs:
|
||||||
|
```
|
||||||
|
[BrowserWhisper] Starting preload...
|
||||||
|
[BrowserWhisper] Progress: onnx-runtime 0%
|
||||||
|
[BrowserWhisper] Progress: model.bin 23%
|
||||||
|
[BrowserWhisper] Progress: model.bin 47%
|
||||||
|
...
|
||||||
|
[BrowserWhisper] Progress: 100%
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expected Download Times:
|
||||||
|
- **Tiny** (39 MB): 5-15 seconds (fast connection)
|
||||||
|
- **Base** (74 MB): 10-30 seconds
|
||||||
|
- **Small** (244 MB): 30-90 seconds
|
||||||
|
|
||||||
|
### If Still Stuck:
|
||||||
|
**Check these:**
|
||||||
|
1. Open DevTools → Network tab
|
||||||
|
2. Filter by "HuggingFace"
|
||||||
|
3. Look for downloads from `cdn-lfs-us-1.huggingface.co`
|
||||||
|
4. Check if files are actually downloading
|
||||||
|
|
||||||
|
**Common issues:**
|
||||||
|
- Slow internet connection (244 MB takes time!)
|
||||||
|
- Corporate firewall blocking HuggingFace CDN
|
||||||
|
- Browser IndexedDB quota exceeded
|
||||||
|
|
||||||
|
**Workaround:**
|
||||||
|
Just enable it and record audio - the model will download on first use (same as pre-download, but triggered automatically).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔊 **TTS Voice Preview**
|
||||||
|
|
||||||
|
### Issue You Reported:
|
||||||
|
"Preview button next to TTS seems to do nothing"
|
||||||
|
|
||||||
|
### Fixed:
|
||||||
|
- ✅ Added error logging to console
|
||||||
|
- ✅ Better validation (checks for empty selection)
|
||||||
|
- ✅ Clear user feedback messages
|
||||||
|
|
||||||
|
### How to Use:
|
||||||
|
1. Go to Settings → Voice Preferences
|
||||||
|
2. Select a voice from "Text-to-Speech Voice" dropdown
|
||||||
|
3. Click "Preview" button
|
||||||
|
4. Wait 2-3 seconds
|
||||||
|
5. Audio should play automatically
|
||||||
|
|
||||||
|
### If Nothing Happens:
|
||||||
|
**Check browser console for errors:**
|
||||||
|
- Open DevTools (F12) → Console tab
|
||||||
|
- Click Preview
|
||||||
|
- Look for `[VoicePrefs] Preview error:` message
|
||||||
|
|
||||||
|
**Common issues:**
|
||||||
|
1. **No voice selected** → Select from dropdown first
|
||||||
|
2. **TTS not configured** → Check `.env` has `GOOGLE_VERTEX_PROJECT` or `LITELLM_API_BASE`
|
||||||
|
3. **Network error** → Check server logs for TTS API errors
|
||||||
|
4. **Browser autoplay policy** → Some browsers block autoplay, click page first
|
||||||
|
|
||||||
|
### Testing Checklist:
|
||||||
|
```bash
|
||||||
|
# 1. Check TTS is configured
|
||||||
|
curl http://localhost:3000/api/health | grep tts
|
||||||
|
|
||||||
|
# 2. Test TTS endpoint directly
|
||||||
|
curl -X POST http://localhost:3000/api/text-to-speech \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"text":"Test"}' \
|
||||||
|
--output test.mp3
|
||||||
|
|
||||||
|
# 3. Play the audio file
|
||||||
|
mpg123 test.mp3 # or open in browser
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 **Summary of User Settings**
|
||||||
|
|
||||||
|
### Voice Preferences
|
||||||
|
**Location:** Settings → Voice Preferences (top section)
|
||||||
|
|
||||||
|
| Setting | Options | Default | Purpose |
|
||||||
|
|---------|---------|---------|---------|
|
||||||
|
| **STT Model** | gemini-2.0-flash-exp, gemini-2.0-flash, gemini-1.5-flash, gemini-1.5-pro, whisper-1 | Server default | Controls transcription accuracy |
|
||||||
|
| **TTS Voice** | Journey-F/D, Studio-O/M, Neural2 series, alloy, echo, fable, onyx, nova, shimmer | Server default | Controls read-aloud voice |
|
||||||
|
|
||||||
|
### Browser Whisper
|
||||||
|
**Location:** Settings → Browser Transcription (Local Whisper)
|
||||||
|
|
||||||
|
| Setting | Options | Default | Purpose |
|
||||||
|
|---------|---------|---------|---------|
|
||||||
|
| **Enable** | On/Off | Off | Local transcription (HIPAA-safe) |
|
||||||
|
| **Model** | Tiny, Base, Small | Tiny | Accuracy vs speed tradeoff |
|
||||||
|
|
||||||
|
### Nextcloud
|
||||||
|
**Location:** Settings → Nextcloud Integration
|
||||||
|
|
||||||
|
| Setting | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| **Nextcloud URL** | Your Nextcloud instance |
|
||||||
|
| **Username** | Nextcloud username |
|
||||||
|
| **App Password** | Generate in Nextcloud → Security |
|
||||||
|
| **Default Browse Path** | Starting folder for Learning Hub AI picker |
|
||||||
|
|
||||||
|
### Documents (S3)
|
||||||
|
**Location:** Settings → Documents
|
||||||
|
|
||||||
|
Shows list of uploaded documents if S3 is configured. Upload limit: 10 MB per file.
|
||||||
|
|
||||||
|
### Audio Backups
|
||||||
|
**Location:** Settings → Audio Backups
|
||||||
|
|
||||||
|
Shows last 24 hours of recordings. Can retry transcription or delete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 **Troubleshooting Guide**
|
||||||
|
|
||||||
|
### Pre-Download Stuck
|
||||||
|
1. ✅ Open browser console (F12)
|
||||||
|
2. ✅ Look for `[BrowserWhisper] Progress:` logs
|
||||||
|
3. ✅ Check Network tab for HuggingFace downloads
|
||||||
|
4. ✅ Wait - 244 MB takes time!
|
||||||
|
5. ✅ If truly stuck (no network activity): refresh page, try again
|
||||||
|
|
||||||
|
### Preview Button Silent
|
||||||
|
1. ✅ Check voice is selected in dropdown
|
||||||
|
2. ✅ Open console for error messages
|
||||||
|
3. ✅ Test TTS endpoint directly (curl command above)
|
||||||
|
4. ✅ Check server logs for TTS provider errors
|
||||||
|
5. ✅ Verify `.env` has TTS provider configured
|
||||||
|
|
||||||
|
### S3 Not Working
|
||||||
|
1. ✅ Check `.env` has `S3_BUCKET` set
|
||||||
|
2. ✅ Verify credentials: `S3_ACCESS_KEY_ID` + `S3_SECRET_ACCESS_KEY`
|
||||||
|
3. ✅ Test bucket access from server:
|
||||||
|
```bash
|
||||||
|
aws s3 ls s3://your-bucket/ --region us-east-1
|
||||||
|
```
|
||||||
|
4. ✅ Check server logs for S3 errors when uploading
|
||||||
|
|
||||||
|
### Audio Backups Not Showing
|
||||||
|
1. ✅ Record audio first (they're created on recording, not transcription)
|
||||||
|
2. ✅ Check database: `SELECT COUNT(*) FROM audio_backups;`
|
||||||
|
3. ✅ Verify IndexedDB in browser: DevTools → Application → IndexedDB → `PedScribeAudioBackup`
|
||||||
|
4. ✅ Backups auto-delete after 24 hours
|
||||||
|
|
||||||
|
### Learning Hub Path Not Working
|
||||||
|
1. ✅ This only affects **AI content generator file picker**
|
||||||
|
2. ✅ It does NOT affect manual Nextcloud document browsing
|
||||||
|
3. ✅ Path must exist in your Nextcloud
|
||||||
|
4. ✅ Path format: `/Folder/Subfolder` (starts with `/`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **Feature Status Matrix**
|
||||||
|
|
||||||
|
| Feature | Status | Config Required | HIPAA-Safe | Notes |
|
||||||
|
|---------|--------|-----------------|------------|-------|
|
||||||
|
| **Audio Backups** | ✅ Working | None (auto) | ✅ Yes | Server + IndexedDB |
|
||||||
|
| **S3 Documents** | ✅ Working | S3_BUCKET | ✅ Yes (AWS) | Optional feature |
|
||||||
|
| **Browser Whisper** | ✅ Working | None (optional) | ✅ Yes | Client-side only |
|
||||||
|
| **Voice Preferences** | ✅ Working | Provider config | Depends | Google/AWS = yes |
|
||||||
|
| **Learning Hub Path** | ✅ Working | Nextcloud config | ✅ Yes | User preference |
|
||||||
|
| **TTS Preview** | ✅ Fixed | TTS provider | Depends | Check logs if fails |
|
||||||
|
| **Embeddings** | ✅ Working | Vertex/LiteLLM | ✅ Yes | Requires pgvector |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 **Next Steps**
|
||||||
|
|
||||||
|
1. **Push v14 to Docker** (in progress via GitHub Actions)
|
||||||
|
2. **Test features after deployment**
|
||||||
|
3. **Check browser console for any errors**
|
||||||
|
4. **Verify TTS preview works with your provider**
|
||||||
|
5. **Test browser whisper download with different models**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Questions? Check the logs:**
|
||||||
|
- Browser: F12 → Console tab
|
||||||
|
- Server: `docker logs pediatric-ai-scribe -f`
|
||||||
|
- Database: `psql -d pedscribe -c "SELECT COUNT(*) FROM audio_backups;"`
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ This is the highest-impact improvement for adoption but also the most complex to
|
||||||
|
|
||||||
### 5. Offline Mode
|
### 5. Offline Mode
|
||||||
|
|
||||||
**Current state:** The app requires configured server-side providers for AI generation and final transcription. Browser Whisper has been removed from the runtime.
|
**Current state:** The app requires an internet connection for AI generation and cloud-based transcription. Browser Whisper works offline for transcription only.
|
||||||
|
|
||||||
**Improvement:** Add a local AI model option (e.g., a small medical LLM running on the device or local server) so the entire workflow — record, transcribe, generate note — can happen without any network calls. This would be valuable for:
|
**Improvement:** Add a local AI model option (e.g., a small medical LLM running on the device or local server) so the entire workflow — record, transcribe, generate note — can happen without any network calls. This would be valuable for:
|
||||||
- Rural clinics with unreliable internet
|
- Rural clinics with unreliable internet
|
||||||
|
|
@ -74,9 +74,9 @@ Each specialty has unique documentation requirements that could be addressed wit
|
||||||
|
|
||||||
### 7. Billing Code Suggestions
|
### 7. Billing Code Suggestions
|
||||||
|
|
||||||
**Current state:** Post-note billing suggestions are active as clinician-facing helper panels on supported note outputs.
|
**Current state:** The well visit tab includes some billing code references.
|
||||||
|
|
||||||
**Further improvement:** Improve payer-specific rules, add institution-specific favorites, and add export formats that match common EHR coding workflows.
|
**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.
|
||||||
|
|
||||||
### 8. Quality Metrics Dashboard
|
### 8. Quality Metrics Dashboard
|
||||||
|
|
||||||
|
|
@ -85,7 +85,7 @@ Each specialty has unique documentation requirements that could be addressed wit
|
||||||
**Improvement:** Add a dashboard showing:
|
**Improvement:** Add a dashboard showing:
|
||||||
- Average note generation time by type
|
- Average note generation time by type
|
||||||
- Most-used AI models and their accuracy (based on how often users edit the output)
|
- Most-used AI models and their accuracy (based on how often users edit the output)
|
||||||
- Transcription quality metrics from explicit user feedback or retry outcomes
|
- Transcription accuracy metrics (if corrections are tracked)
|
||||||
- Usage patterns by time of day and day of week
|
- Usage patterns by time of day and day of week
|
||||||
- Cost tracking across AI providers
|
- Cost tracking across AI providers
|
||||||
|
|
||||||
|
|
@ -93,9 +93,9 @@ This would help administrators optimize model selection and identify training op
|
||||||
|
|
||||||
### 9. Patient Education Materials
|
### 9. Patient Education Materials
|
||||||
|
|
||||||
**Current state:** 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.
|
**Current state:** The Learning Hub serves educational content to physicians.
|
||||||
|
|
||||||
**Further improvement:** Add handout templates, saved handout history, institution-approved language libraries, and printable/PDF export.
|
**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.
|
||||||
|
|
||||||
### 10. Multi-Language Support
|
### 10. Multi-Language Support
|
||||||
|
|
||||||
|
|
@ -140,7 +140,7 @@ This mirrors the real workflow in training institutions and group practices.
|
||||||
|
|
||||||
### 14. Template Library
|
### 14. Template Library
|
||||||
|
|
||||||
**Current state:** Physician templates and prompt preferences provide per-user personalization. Legacy correction-learning rows may exist but are no longer active behavior.
|
**Current state:** Physician memories and corrections provide some personalization.
|
||||||
|
|
||||||
**Improvement:** Add a shared template library where physicians can create, share, and browse note templates:
|
**Improvement:** Add a shared template library where physicians can create, share, and browse note templates:
|
||||||
- "My asthma follow-up template"
|
- "My asthma follow-up template"
|
||||||
|
|
@ -182,7 +182,7 @@ Compared to existing medical scribes and documentation tools:
|
||||||
|
|
||||||
- **Pediatric-specific** — prompts, calculators, milestones, and growth charts designed for children, not adapted from adult tools
|
- **Pediatric-specific** — prompts, calculators, milestones, and growth charts designed for children, not adapted from adult tools
|
||||||
- **Self-hosted** — runs on your own infrastructure, not a SaaS that holds your data
|
- **Self-hosted** — runs on your own infrastructure, not a SaaS that holds your data
|
||||||
- **Provider-flexible** — routes through OpenRouter, Bedrock, Azure, Vertex, or LiteLLM depending on deployment configuration
|
- **Provider-agnostic** — works with any AI provider (swap between them without changing anything)
|
||||||
- **Privacy-conscious** — self-hosted app, encrypted sensitive fields, auto-expiring encounter/audio recovery data, and configurable BAA-eligible providers
|
- **Privacy-first** — optional fully offline transcription, auto-expiring data, no permanent PHI storage
|
||||||
- **Template-aware** — user templates and prompt preferences can shape output without relying on automatic correction learning
|
- **Learning system** — AI improves its output based on each physician's editing patterns
|
||||||
- **All-in-one** — documentation, calculators, education, and administration in a single platform
|
- **All-in-one** — documentation, calculators, education, and administration in a single platform
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
> Deep, dev-friendly documentation of how each part of the ped-ai app
|
> Deep, dev-friendly documentation of how each part of the ped-ai app
|
||||||
> actually works. Written so a human developer can understand the
|
> actually works. Written so a human developer can understand the
|
||||||
> codebase without spelunking, and so an AI assistant can confidently
|
> codebase without spelunking, and so an AI assistant can confidently
|
||||||
> modify code without breaking high-risk workflows.
|
> modify code without breaking sacred zones.
|
||||||
|
|
||||||
These docs explain **application logic** — what the user does, what the
|
These docs explain **application logic** — what the user does, what the
|
||||||
system does in response, what the data flow is, and **why** the design
|
system does in response, what the data flow is, and **why** the design
|
||||||
|
|
@ -16,9 +16,9 @@ recipes (see [`../deployment.md`](../deployment.md)).
|
||||||
For someone brand new to the codebase:
|
For someone brand new to the codebase:
|
||||||
|
|
||||||
1. **[architecture.md](architecture.md)** — Start here. The big picture:
|
1. **[architecture.md](architecture.md)** — Start here. The big picture:
|
||||||
current frontend pattern, lazy tab loading, backend route convention,
|
IIFE frontend pattern, lazy tab loading, backend route convention,
|
||||||
PostgreSQL schema, encryption at rest, Dockerfile + compose layout,
|
PostgreSQL schema, encryption at rest, Dockerfile + compose layout,
|
||||||
and high-risk zones.
|
sacred zones. (~2,000 lines, the longest doc — but the foundation.)
|
||||||
|
|
||||||
2. **[clinical-notes.md](clinical-notes.md)** — How every clinical note
|
2. **[clinical-notes.md](clinical-notes.md)** — How every clinical note
|
||||||
tab works. The shared "record → transcribe → generate → save"
|
tab works. The shared "record → transcribe → generate → save"
|
||||||
|
|
@ -33,15 +33,17 @@ For someone brand new to the codebase:
|
||||||
composed in this codebase. Read this for a worked example.
|
composed in this codebase. Read this for a worked example.
|
||||||
|
|
||||||
4. **[bedside-and-calculators.md](bedside-and-calculators.md)** —
|
4. **[bedside-and-calculators.md](bedside-and-calculators.md)** —
|
||||||
Bedside emergencies module, the pediatric calculators (BP percentile, Fenton growth,
|
Bedside emergencies module (the one ES-module pocket of the
|
||||||
|
frontend), the pediatric calculators (BP percentile, Fenton growth,
|
||||||
bilirubin nomograms, etc.), the PE Guide, vax schedule, milestones.
|
bilirubin nomograms, etc.), the PE Guide, vax schedule, milestones.
|
||||||
Includes the suture selector. **Important:** lists every clinical
|
Includes the suture selector. **Important:** lists every clinical
|
||||||
formula that must NOT be modified without test vectors.
|
formula that must NOT be modified without test vectors.
|
||||||
|
|
||||||
5. **[ai-and-voice.md](ai-and-voice.md)** — AI provider routing
|
5. **[ai-and-voice.md](ai-and-voice.md)** — The 5-provider AI routing
|
||||||
(`callAI`), the centralized `PROMPTS` object with DB overrides, the
|
(`callAI`), the centralized `PROMPTS` object with DB overrides, the
|
||||||
`wrapUserText` + `INJECTION_GUARD` safety pattern, server-side STT
|
`wrapUserText` + `INJECTION_GUARD` safety pattern, server-side STT
|
||||||
routing, TTS, and the AudioRecorder. Voice/STT plumbing is high-risk — the
|
routing (Whisper / AWS Transcribe / Vertex / LiteLLM), browser
|
||||||
|
Whisper, the AudioRecorder. Voice/STT plumbing is **sacred** — the
|
||||||
doc describes it without proposing changes.
|
doc describes it without proposing changes.
|
||||||
|
|
||||||
6. **[auth-admin-learning.md](auth-admin-learning.md)** — Authentication
|
6. **[auth-admin-learning.md](auth-admin-learning.md)** — Authentication
|
||||||
|
|
@ -70,34 +72,43 @@ Each doc follows the same structure:
|
||||||
- **Data flow** — what HTTP calls happen, what the server does
|
- **Data flow** — what HTTP calls happen, what the server does
|
||||||
- **File map** — which files do what
|
- **File map** — which files do what
|
||||||
- **Key design decisions** — *why* it works the way it does
|
- **Key design decisions** — *why* it works the way it does
|
||||||
- **High-risk zones** — what requires small, tested changes
|
- **Sacred zones** — what NOT to refactor without explicit approval
|
||||||
- **How to extend** — concrete recipes for adding a new X
|
- **How to extend** — concrete recipes for adding a new X
|
||||||
|
|
||||||
When a doc mentions a high-risk zone, changes should be small, well-tested, and
|
When a doc mentions a sacred zone, it means there's a project-memory
|
||||||
directly tied to the requested behavior. Current high-risk areas:
|
rule that this code must not be refactored without per-change approval
|
||||||
|
from Daniel. The full sacred-zone roster:
|
||||||
|
|
||||||
| Zone | Why |
|
| Zone | Why |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `public/js/encounters.js` save/load/idempotency | Save/version/idempotency logic has been carefully tuned; refactors keep silently breaking it. |
|
| `public/js/encounters.js` save/load/idempotency | Save/version/idempotency logic has been carefully tuned; refactors keep silently breaking it. |
|
||||||
| Voice/STT plumbing (`audioBackup.js`, `speechRecognition.js`, `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`, `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. |
|
||||||
| Validated clinical formulas (BP percentile LMS, Fenton 2013, bilirubin AAP 2022, Bhutani, APLS / Best-Guess weight, PE Guide SCALES) | Validated against peditools / AAP tables; modifying without test vectors risks miscoding patient care. |
|
| Validated clinical formulas (BP percentile LMS, Fenton 2013, bilirubin AAP 2022, Bhutani, APLS / Best-Guess weight, PE Guide SCALES) | Validated against peditools / AAP tables; modifying without test vectors risks miscoding patient care. |
|
||||||
| Auth + crypto (`crypto.js`, `passwords.js`, `sessions.js`, `auth.js`, `oidc.js`) | Security; changes without security review are unsafe. |
|
| Auth + crypto (`crypto.js`, `passwords.js`, `sessions.js`, `auth.js`, `oidc.js`) | Security; changes without security review are unsafe. |
|
||||||
| MDM rubric in `PROMPTS.edFinalize` | Load-bearing for billing accuracy; trim only with explicit AMA/coding source citation. |
|
| MDM rubric in `PROMPTS.edFinalize` | Load-bearing for billing accuracy; trim only with explicit AMA/coding source citation. |
|
||||||
|
|
||||||
|
## Total size
|
||||||
|
|
||||||
|
~8,300 lines of new application-logic documentation across 6 files. If
|
||||||
|
that feels like a lot, remember: the codebase is ~33,000 lines of
|
||||||
|
frontend JS + ~14,000 lines of backend JS. The docs are dense by design
|
||||||
|
— "200% detailed" was the explicit ask. Search them like a reference;
|
||||||
|
don't try to read end to end.
|
||||||
|
|
||||||
## Cross-cutting topics
|
## Cross-cutting topics
|
||||||
|
|
||||||
A few topics span multiple docs. Use these as your jump-off points:
|
A few topics span multiple docs. Use these as your jump-off points:
|
||||||
|
|
||||||
| Topic | Where to look |
|
| Topic | Where to look |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Frontend globals, ES modules, and lazy tab loading | architecture.md |
|
| The IIFE pattern + `window.x = y` cross-file globals | architecture.md §2-3 |
|
||||||
| Lazy tab loading (`loadComponent`, `tabChanged` event) | architecture.md |
|
| Lazy tab loading (`loadComponent`, `tabChanged` event) | architecture.md §3-4 |
|
||||||
| `getUserMemoryContext` → templates feeding into AI prompts | clinical-notes.md §6, ed-encounters.md §9 |
|
| `getUserMemoryContext` → templates feeding into AI prompts | clinical-notes.md §6, ed-encounters.md §9 |
|
||||||
| The helper trio: `refineDocument`, `suggestBillingCodes`, `suggestDontMiss` | ai-and-voice.md §12, clinical-notes.md §5 |
|
| The helper trio: `refineDocument`, `suggestBillingCodes`, `suggestDontMiss` | ai-and-voice.md §12, clinical-notes.md §5 |
|
||||||
| `wrapUserText` + `INJECTION_GUARD` prompt-injection defense | ai-and-voice.md §5 |
|
| `wrapUserText` + `INJECTION_GUARD` prompt-injection defense | ai-and-voice.md §5 |
|
||||||
| `saveEncounter` API + optimistic locking + idempotency keys | architecture.md §13, clinical-notes.md §4, ed-encounters.md §5 |
|
| `saveEncounter` API + optimistic locking + idempotency keys | architecture.md §13, clinical-notes.md §4, ed-encounters.md §5 |
|
||||||
| `cryptoUtil.encryptString` / `encryptBuffer` "enc1:" format | architecture.md §12 |
|
| `cryptoUtil.encryptString` / `encryptBuffer` "enc1:" format | architecture.md §12 |
|
||||||
| AI provider routing (`callAI`) | ai-and-voice.md §2-3 |
|
| 5-provider AI routing (`callAI`) | ai-and-voice.md §2-3 |
|
||||||
| 2023 AMA E/M MDM rubric | ed-encounters.md §6 |
|
| 2023 AMA E/M MDM rubric | ed-encounters.md §6 |
|
||||||
| User templates (`user_memories` table, `template_*` categories) | clinical-notes.md §6, ed-encounters.md §9 |
|
| User templates (`user_memories` table, `template_*` categories) | clinical-notes.md §6, ed-encounters.md §9 |
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,45 +1,821 @@
|
||||||
# ED Encounters
|
# ED Encounters — Application Logic
|
||||||
|
|
||||||
The ED encounter workflow is a multi-stage clinical documentation flow for
|
> Multi-stage emergency department documentation with per-stage AI generation,
|
||||||
emergency visits.
|
> "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.
|
||||||
|
|
||||||
## Shape
|
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.
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Typical Flow
|
## 1. What this is
|
||||||
|
|
||||||
1. Capture initial ED context and generate an initial note/stage output.
|
An ED encounter is structurally different from every other clinical note in
|
||||||
2. Add interval updates as the encounter evolves.
|
the app:
|
||||||
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.
|
|
||||||
|
|
||||||
## Design Constraints
|
- 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.
|
||||||
|
|
||||||
- Later stages should not silently overwrite earlier clinical text.
|
The user-visible model: each generated stage stays on screen as its own
|
||||||
- Regeneration should make it clear which stage is being updated.
|
editable card with its own "Don't Miss" panel. The physician can edit any
|
||||||
- MDM/finalization prompt changes should be conservative and coding-aware.
|
stage at any time. Whatever's on screen at finalize time is what gets sent
|
||||||
- Don't-miss output is clinician-facing safety support, not a replacement for
|
to the consolidate step.
|
||||||
clinical judgment.
|
|
||||||
|
|
||||||
## User Templates
|
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.
|
||||||
|
|
||||||
Templates saved under ED-relevant categories can be included through
|
---
|
||||||
`/api/memories/context` and passed as `physicianMemories`. Legacy
|
|
||||||
`correction_*` rows are filtered out.
|
|
||||||
|
|
||||||
## Testing Checklist
|
## 2. The user flow, end to end
|
||||||
|
|
||||||
When changing ED behavior:
|
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.
|
||||||
|
|
||||||
1. Run syntax checks for `public/js/ed-encounters.js` and
|
---
|
||||||
`src/routes/edEncounters.js`.
|
|
||||||
2. Run `npm test`.
|
## 3. State model
|
||||||
3. Manually test stage generation, finalization, save/load, and helper panels
|
|
||||||
in an authenticated session when possible.
|
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,8 +1,7 @@
|
||||||
# Mobile Build And Release
|
# Mobile build & release
|
||||||
|
|
||||||
Capacitor 6 wrapper around the hosted Ped-AI web app. The launcher defaults to `https://app.pedshub.com`, lets the user change the server URL, and stores that URL locally. Android is buildable on Linux. The iOS project exists but requires macOS and Xcode to produce an `.ipa`.
|
Capacitor 6 wrapper. Android only today; iOS project exists but requires macOS
|
||||||
|
+ Xcode to produce an `.ipa`.
|
||||||
This is not a separate native clinical app. The native shell provides WebView hosting, microphone permission plumbing, secure storage, and mobile packaging for the same authenticated web app.
|
|
||||||
|
|
||||||
## One-time setup
|
## One-time setup
|
||||||
|
|
||||||
|
|
@ -26,12 +25,8 @@ npx cap open android
|
||||||
|
|
||||||
## CI build (preferred)
|
## CI build (preferred)
|
||||||
|
|
||||||
Push-triggered. Any push to `main`/feature branches and any `vX.Y.Z` tag push
|
Tag-triggered. Push any `vX.Y.Z` tag → `.github/workflows/android-release.yml`
|
||||||
→ `.forgejo/workflows/android-apk.yml` builds a signed APK on the Forgejo
|
builds a signed APK on a GitHub runner and attaches it to the matching release.
|
||||||
runner.
|
|
||||||
|
|
||||||
Tagged builds additionally publish the artifact to the matching Forgejo release
|
|
||||||
as `pedscribe-<tag>.apk` so Obtainium can track updates.
|
|
||||||
|
|
||||||
Required repo secrets (set once, via Settings → Secrets and variables → Actions
|
Required repo secrets (set once, via Settings → Secrets and variables → Actions
|
||||||
or `gh secret set`):
|
or `gh secret set`):
|
||||||
|
|
@ -40,14 +35,6 @@ or `gh secret set`):
|
||||||
- `ANDROID_KEYSTORE_PASSWORD`
|
- `ANDROID_KEYSTORE_PASSWORD`
|
||||||
- `ANDROID_KEY_ALIAS` — `pedscribe`
|
- `ANDROID_KEY_ALIAS` — `pedscribe`
|
||||||
- `ANDROID_KEY_PASSWORD`
|
- `ANDROID_KEY_PASSWORD`
|
||||||
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64` — base64 of your Google Play service
|
|
||||||
account JSON (optional). If present, the same tag build also runs `bundleRelease`
|
|
||||||
and uploads the AAB to Play's `internal` track.
|
|
||||||
|
|
||||||
Optional Play Store flow:
|
|
||||||
- Service account must have permissions to edit releases on the app in Play.
|
|
||||||
- Build task is `bundleRelease`, tracked as `com.pedshub.scribe`.
|
|
||||||
- Upload lane is `fastlane/android publish_internal` (under `mobile/android/fastlane`).
|
|
||||||
|
|
||||||
Tag a release:
|
Tag a release:
|
||||||
|
|
||||||
|
|
@ -57,13 +44,12 @@ git commit -m "feat: ..." && git push # auto-version workflow bumps minor
|
||||||
git commit -m "fix: ..." && git push # auto-version workflow bumps patch
|
git commit -m "fix: ..." && git push # auto-version workflow bumps patch
|
||||||
|
|
||||||
# or force an exact version
|
# or force an exact version
|
||||||
scripts/release.sh X.Y.Z --push
|
scripts/release.sh 6.2.0 --push
|
||||||
```
|
```
|
||||||
|
|
||||||
APK lands on the Forgejo release. Obtainium can still track
|
APK lands at the GitHub release; `/releases/latest` link in the login page
|
||||||
`git.danvics.com/danvics/pediatric-ai-scribe-v3` releases automatically.
|
resolves to it automatically. Obtanium subscribers (`github.com/<owner>/<repo>`)
|
||||||
Play Store upload is handled automatically for tagged builds only when
|
pick up the update on next poll.
|
||||||
`GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64` is configured.
|
|
||||||
|
|
||||||
## Local build (fallback / debugging)
|
## Local build (fallback / debugging)
|
||||||
|
|
||||||
|
|
@ -83,8 +69,6 @@ Output: `android/app/build/outputs/apk/release/app-release.apk`
|
||||||
For Play Store, swap `assembleRelease` → `bundleRelease`; output: `.aab` under
|
For Play Store, swap `assembleRelease` → `bundleRelease`; output: `.aab` under
|
||||||
`bundle/release/`.
|
`bundle/release/`.
|
||||||
|
|
||||||
If web assets or Capacitor config changed, run `npx cap sync android` from `mobile/` before building.
|
|
||||||
|
|
||||||
### Single-quote the password
|
### Single-quote the password
|
||||||
|
|
||||||
Keystore passwords with shell metacharacters (`)`, `$`, `!`, space, etc.) must
|
Keystore passwords with shell metacharacters (`)`, `$`, `!`, space, etc.) must
|
||||||
|
|
@ -125,9 +109,8 @@ user to uninstall + reinstall.
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `mobile/capacitor.config.json` | appId, name, WebView config, plugin opts |
|
| `mobile/capacitor.config.json` | appId, name, WebView config, plugin opts |
|
||||||
| `mobile/src/` | launcher HTML and server URL entry, defaulting to `https://app.pedshub.com` |
|
| `mobile/src/` | launcher HTML (server URL entry) |
|
||||||
| `mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java` | JS bridge + WebView mic permission |
|
| `mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java` | JS bridge + WebView mic permission |
|
||||||
| `mobile/android/app/src/main/java/com/pedshub/scribe/AudioRecordingService.java` | foreground service for background recording |
|
| `mobile/android/app/src/main/java/com/pedshub/scribe/AudioRecordingService.java` | foreground service for background recording |
|
||||||
| `mobile/android/app/src/main/AndroidManifest.xml` | permissions, intents, backup rules |
|
| `mobile/android/app/src/main/AndroidManifest.xml` | permissions, intents, backup rules |
|
||||||
| `.forgejo/workflows/android-apk.yml` | CI build |
|
| `.github/workflows/android-release.yml` | CI build |
|
||||||
| `mobile/android/fastlane/Fastfile` | internal Play track upload lane |
|
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,83 @@
|
||||||
# Speech: STT, TTS, Audio Backup
|
# Speech: STT, TTS, audio backup
|
||||||
|
|
||||||
## Transcription
|
## Transcription (speech-to-text)
|
||||||
|
|
||||||
`POST /api/transcribe` accepts `multipart/form-data` with one audio file up to 25 MB. Server STT is routed through LiteLLM.
|
### Overview
|
||||||
|
|
||||||
Set `TRANSCRIBE_PROVIDER=litellm`, `LITELLM_API_BASE`, and `LITELLM_STT_MODEL`. Auto mode also uses LiteLLM when the gateway is configured.
|
`POST /api/transcribe` accepts `multipart/form-data` with a single audio
|
||||||
|
file (≤ 25 MB). Provider is `TRANSCRIBE_PROVIDER` env var, or auto-detected
|
||||||
|
(`google > aws > openai`) from available credentials. Each user may override
|
||||||
|
via `users.stt_model`; admin-wide default via `stt.model` in `app_settings`.
|
||||||
|
|
||||||
| Provider | Notes | HIPAA posture |
|
### Providers
|
||||||
|
|
||||||
|
| Provider | Transport | HIPAA (with BAA) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| LiteLLM | Sends audio through the configured LiteLLM `/audio/transcriptions` backend. | Depends on the selected upstream. |
|
| **Google Gemini** | Inline audio in `generateContent` call. Default model `gemini-2.0-flash`. | Yes |
|
||||||
|
| **Amazon Transcribe** | Streaming. `AWS_TRANSCRIBE_MEDICAL=true` + `AWS_TRANSCRIBE_SPECIALTY` switches to Transcribe Medical. Specialties: `PRIMARYCARE`, `CARDIOLOGY`, `NEUROLOGY`, `ONCOLOGY`, `RADIOLOGY`, `UROLOGY`. | Yes |
|
||||||
|
| **Local Whisper** | Spawns `whisper.cpp` or `faster-whisper` via `WHISPER_BINARY`. Fully offline. Model sizes `tiny`/`base`/`small`/`medium`/`large`. | N/A (nothing leaves host) |
|
||||||
|
| **OpenAI Whisper** | `whisper-1` via `/v1/audio/transcriptions`. Medical-context prompt prepended: `"Medical patient encounter. Pediatric."` | No |
|
||||||
|
| **LiteLLM** | Inline audio via LiteLLM's `chat.completions` endpoint (not the `/audio/transcriptions` path). Model from `LITELLM_STT_MODEL`. | Depends on LiteLLM backend |
|
||||||
|
|
||||||
Browser Whisper 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.
|
## Browser Whisper (fully offline)
|
||||||
|
|
||||||
## Web Speech Preview
|
Runs entirely in the browser via WebAssembly. Zero network. Suitable when
|
||||||
|
no external transcription is acceptable.
|
||||||
|
|
||||||
Browser-native Web Speech can show interim text when the user explicitly enables it. It is browser/vendor dependent, may send audio to browser-provider cloud services, and should not be treated as the final clinical transcript.
|
- Runtime: `@xenova/transformers` (WASM).
|
||||||
|
- Models (bundled in the Docker image, no CDN fetch):
|
||||||
|
- `whisper-tiny.en` — 39 MB
|
||||||
|
- `whisper-base.en` — 74 MB
|
||||||
|
- `whisper-small.en` — 244 MB
|
||||||
|
- Executes in a dedicated Web Worker; UI thread is never blocked.
|
||||||
|
- Models cached in IndexedDB after first load.
|
||||||
|
- Per-user toggle. On browser transcription failure, the client falls back to
|
||||||
|
server-side transcription without user intervention.
|
||||||
|
|
||||||
## Text To Speech
|
## Live speech preview
|
||||||
|
|
||||||
`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.
|
Chrome / Edge `webkitSpeechRecognition` streams interim text to the UI during
|
||||||
|
recording. Used for real-time preview only — **not** for final transcription.
|
||||||
|
The actual transcript comes from the configured STT provider after recording
|
||||||
|
ends.
|
||||||
|
|
||||||
|
## Text-to-speech
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
`POST /api/text-to-speech`. Returns `audio/mpeg`. `X-TTS-Provider` response
|
||||||
|
header identifies the provider used. 5000-character limit per request. Each
|
||||||
|
user may override via `users.tts_voice`; admin-wide default via `tts.voice`.
|
||||||
|
|
||||||
|
### Providers
|
||||||
|
|
||||||
| Provider | Notes |
|
| Provider | Notes |
|
||||||
|---|---|
|
|---|---|
|
||||||
| LiteLLM | Uses `LITELLM_TTS_MODEL` and `LITELLM_TTS_VOICE`. |
|
| **Google Cloud TTS** | `@google-cloud/text-to-speech`. Voice families: Journey, Studio, Neural2. |
|
||||||
|
| **LiteLLM** | Configured via `LITELLM_TTS_MODEL` + `LITELLM_TTS_VOICE`. Backend-agnostic. |
|
||||||
|
| **ElevenLabs** | `eleven_turbo_v2_5`. **Not HIPAA-compliant**. |
|
||||||
|
|
||||||
The admin/user voice pickers read available LiteLLM-compatible voices from `LITELLM_TTS_VOICES`.
|
## Audio backup
|
||||||
|
|
||||||
## Audio Backup
|
Raw audio is saved to Postgres **only when transcription fails**, providing a
|
||||||
|
retry window without persisting every recording.
|
||||||
|
|
||||||
Failed transcription submissions can be stored for retry instead of being silently lost.
|
### Storage
|
||||||
|
|
||||||
- Audio backups are compressed and encrypted before storage.
|
- Gzip-compressed, then AES-256-GCM encrypted (0x01 version byte prefix).
|
||||||
- Backups expire automatically.
|
- `BYTEA` column in `audio_backups`.
|
||||||
- The Settings audio backup UI can retry or delete saved items.
|
- 24-hour `expires_at`, swept hourly.
|
||||||
- Browser fallback storage is used only when the server cannot save the failed audio.
|
- Legacy rows (gzip magic `0x1F` as first byte, no encryption envelope)
|
||||||
|
decompress as-is — detection is deterministic because `0x1F ≠ 0x01`.
|
||||||
|
|
||||||
Treat audio backups as sensitive clinical data even when encrypted.
|
### Retry UI
|
||||||
|
|
||||||
|
Settings → Audio Backups:
|
||||||
|
- List: module, size, created, expiry.
|
||||||
|
- **Retry** — resubmits to `POST /api/transcribe`.
|
||||||
|
- **Delete** — purge now.
|
||||||
|
|
||||||
|
### Browser fallback
|
||||||
|
|
||||||
|
If the server-side save fails (network, 500, etc.), the client stores the audio
|
||||||
|
in IndexedDB so it can retry later. Cleared after successful submission.
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,279 @@
|
||||||
# Transcription Options
|
# Transcription Options Guide
|
||||||
|
|
||||||
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.
|
## Overview
|
||||||
|
|
||||||
## Recommended Clinical Setup
|
Pediatric AI Scribe v2+ offers **three transcription methods**, allowing you to choose between **privacy**, **speed**, and **real-time feedback**.
|
||||||
|
|
||||||
Route STT through LiteLLM and configure the compliant upstream in LiteLLM.
|
---
|
||||||
|
|
||||||
| Need | Recommended provider |
|
## 📊 Comparison Table
|
||||||
|---|---|
|
|
||||||
| 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.
|
| Feature | Browser Whisper | Server Transcription | Web Speech API |
|
||||||
|
|---------|----------------|---------------------|----------------|
|
||||||
|
| **Privacy** | ⭐⭐⭐⭐⭐ 100% offline | ⭐⭐⭐⭐ (with BAA) | ⭐ Sends to cloud |
|
||||||
|
| **Accuracy** | ⭐⭐⭐⭐⭐ Whisper | ⭐⭐⭐⭐⭐ Gemini/AWS | ⭐⭐⭐ Browser-dependent |
|
||||||
|
| **Speed** | ⭐⭐⭐ 2-10s | ⭐⭐⭐⭐⭐ ~1s | ⭐⭐⭐⭐⭐ Instant |
|
||||||
|
| **Real-time** | ❌ Batch mode | ❌ Batch mode | ✅ Live streaming |
|
||||||
|
| **HIPAA** | ✅ Yes | ✅ (Vertex/AWS) | ❌ No |
|
||||||
|
| **Cost** | Free | ~$0.005/min | Free |
|
||||||
|
| **Internet** | ❌ Not required | ✅ Required | ✅ Required |
|
||||||
|
| **Setup** | None (bundled) | API keys | None (built-in) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 1: Browser Whisper (Offline, Private) ⭐ RECOMMENDED
|
||||||
|
|
||||||
|
### What It Is
|
||||||
|
- Runs **OpenAI Whisper** entirely in your browser using WebAssembly
|
||||||
|
- Audio **never leaves your device** - 100% offline after initial page load
|
||||||
|
- Models bundled in Docker image (self-hosted, no CDN)
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
- ✅ Clinical documentation (HIPAA-compliant)
|
||||||
|
- ✅ Maximum privacy required
|
||||||
|
- ✅ Offline/air-gapped environments
|
||||||
|
- ✅ No API costs
|
||||||
|
- ✅ Zero vendor dependency
|
||||||
|
|
||||||
|
### How to Enable
|
||||||
|
1. Settings → Browser Transcription
|
||||||
|
2. Toggle "Enable browser transcription" ON
|
||||||
|
3. (Optional) Click "Pre-download model" if you want to cache it first
|
||||||
|
4. Start recording - transcription happens automatically after recording
|
||||||
|
|
||||||
|
### Models Available
|
||||||
|
- **Tiny** (~39MB) - Fast, good for short clips (2-3 seconds)
|
||||||
|
- **Base** (~74MB) - Balanced accuracy and speed (3-5 seconds)
|
||||||
|
- **Small** (~244MB) - Best quality, slower (6-10 seconds)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Transcribes ~30-second clip in 2-10 seconds (depending on model)
|
||||||
|
- First run may be slower (model loading)
|
||||||
|
- Subsequent runs are instant (cached)
|
||||||
|
|
||||||
|
### Privacy
|
||||||
|
- ✅ Audio never transmitted
|
||||||
|
- ✅ Models run locally in WASM
|
||||||
|
- ✅ No network calls during transcription
|
||||||
|
- ✅ HIPAA-compliant
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 2: Server Transcription (Cloud, Fast)
|
||||||
|
|
||||||
|
### What It Is
|
||||||
|
- Sends audio to your configured AI provider
|
||||||
|
- Uses Google Gemini, AWS Transcribe, OpenAI Whisper, or LiteLLM
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
- ✅ Maximum speed (~1 second for 30-second clip)
|
||||||
|
- ✅ Best accuracy (cloud models)
|
||||||
|
- ✅ Long recordings (Browser Whisper can be slow for 5+ minutes)
|
||||||
|
- ✅ HIPAA-compliant with BAA providers
|
||||||
|
|
||||||
|
### HIPAA-Eligible Providers
|
||||||
|
- **Google Vertex AI** (with BAA) ✅
|
||||||
|
- **AWS Transcribe** (with BAA) ✅
|
||||||
|
- **Azure OpenAI** (with BAA) ✅
|
||||||
|
- **OpenAI Whisper Direct** ❌ Not HIPAA-eligible
|
||||||
|
|
||||||
|
### How to Enable
|
||||||
|
- Configured via environment variables (`.env`)
|
||||||
|
- No user action needed - just works if API keys present
|
||||||
|
- Falls back automatically if Browser Whisper fails
|
||||||
|
|
||||||
|
### Cost
|
||||||
|
- Google Gemini: ~$0.005/minute
|
||||||
|
- AWS Transcribe: ~$0.024/minute
|
||||||
|
- OpenAI: $0.006/minute
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 3: Web Speech API (Real-Time, Experimental) ⚠️
|
||||||
|
|
||||||
|
### What It Is
|
||||||
|
- Uses your browser's built-in speech recognition
|
||||||
|
- Shows transcription **in real-time** as you speak (streaming)
|
||||||
|
- Chrome/Edge → Google Cloud Speech
|
||||||
|
- Safari → Apple Speech Recognition
|
||||||
|
|
||||||
|
### ⚠️ PRIVACY WARNING
|
||||||
|
- **Audio IS sent to cloud servers** (Google, Apple, etc.)
|
||||||
|
- **NOT HIPAA-compliant**
|
||||||
|
- Only use for non-clinical, personal use
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
- ✅ Personal notes (non-clinical)
|
||||||
|
- ✅ Want real-time feedback while speaking
|
||||||
|
- ✅ Demonstration/testing
|
||||||
|
- ❌ **NEVER for patient data**
|
||||||
|
|
||||||
|
### How to Enable
|
||||||
|
1. Settings → Real-Time Streaming Transcription
|
||||||
|
2. Read privacy warning carefully
|
||||||
|
3. Toggle "Enable real-time streaming" ON
|
||||||
|
4. Confirm warning dialog
|
||||||
|
5. Grants microphone permission
|
||||||
|
6. Start recording - see words appear live
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
- Not available in all browsers (requires Web Speech API)
|
||||||
|
- Accuracy varies by browser
|
||||||
|
- Requires internet connection
|
||||||
|
- May have usage limits
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Choosing the Right Option
|
||||||
|
|
||||||
|
### For Clinical Use (HIPAA Required)
|
||||||
|
**Use:** Browser Whisper (offline) OR Server (Vertex AI/AWS with BAA)
|
||||||
|
- Browser Whisper: Maximum privacy, no costs
|
||||||
|
- Server: Faster, better for long recordings
|
||||||
|
|
||||||
|
### For Personal Use (Non-HIPAA)
|
||||||
|
**Use:** Any option
|
||||||
|
- Browser Whisper: Best balance of privacy and accuracy
|
||||||
|
- Server: Fastest
|
||||||
|
- Web Speech: Real-time feedback
|
||||||
|
|
||||||
|
### Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
Is this clinical/patient data?
|
||||||
|
├─ YES → Use Browser Whisper or Server (Vertex/AWS)
|
||||||
|
│ ├─ Need offline? → Browser Whisper
|
||||||
|
│ ├─ Need speed? → Server (Vertex AI)
|
||||||
|
│ └─ Want free? → Browser Whisper
|
||||||
|
│
|
||||||
|
└─ NO → Any option
|
||||||
|
├─ Want real-time? → Web Speech API
|
||||||
|
├─ Want privacy? → Browser Whisper
|
||||||
|
└─ Want speed? → Server
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
```env
|
### Browser Whisper
|
||||||
TRANSCRIBE_PROVIDER=litellm
|
```bash
|
||||||
LITELLM_API_BASE=https://your-litellm.example/v1
|
# No configuration needed - bundled in Docker image
|
||||||
LITELLM_API_KEY=<key>
|
# Models at: /app/public/models/Xenova/whisper-tiny.en/
|
||||||
LITELLM_STT_MODEL=local-parakeet-v3
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Failure Handling
|
### Server Transcription
|
||||||
|
```bash
|
||||||
|
# .env file
|
||||||
|
TRANSCRIBE_PROVIDER=google # google, aws, openai, litellm
|
||||||
|
|
||||||
- Server transcription failures can create encrypted audio backups for retry.
|
# Google Vertex AI
|
||||||
- Users can retry or delete failed backups from Settings.
|
GOOGLE_VERTEX_PROJECT=your-project-id
|
||||||
- Web Speech interim text is not a substitute for a server transcription response.
|
GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
|
||||||
|
|
||||||
## Removed Paths
|
# AWS Transcribe
|
||||||
|
AWS_BEDROCK_REGION=us-east-1
|
||||||
|
AWS_ACCESS_KEY_ID=your-key
|
||||||
|
AWS_SECRET_ACCESS_KEY=your-secret
|
||||||
|
|
||||||
These should remain absent unless the project intentionally reintroduces browser-local STT with a new design review:
|
# OpenAI
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
|
||||||
- `public/js/browserWhisper.js`
|
# LiteLLM (proxy)
|
||||||
- `public/js/whisperWorker.js`
|
LITELLM_API_BASE=http://localhost:4000
|
||||||
- `public/js/whisperWorkerV2.js`
|
LITELLM_API_KEY=optional
|
||||||
- `public/models/Xenova/*`
|
```
|
||||||
- Browser Whisper setup/troubleshooting docs
|
|
||||||
- Whisper model download scripts for public browser models
|
### Web Speech API
|
||||||
|
```bash
|
||||||
|
# No configuration - uses browser built-in
|
||||||
|
# Privacy warning shown in Settings UI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
### Q: Which is most accurate?
|
||||||
|
**A:** Browser Whisper and Server (Gemini/Whisper) are equally accurate. Web Speech is slightly less accurate.
|
||||||
|
|
||||||
|
### Q: Which is fastest?
|
||||||
|
**A:** Server transcription (~1s) > Web Speech (real-time) > Browser Whisper (2-10s)
|
||||||
|
|
||||||
|
### Q: Which is most private?
|
||||||
|
**A:** Browser Whisper (100% offline) > Server (with BAA) > Web Speech (not private)
|
||||||
|
|
||||||
|
### Q: Can I use multiple at once?
|
||||||
|
**A:** No. Priority: Web Speech > Browser Whisper > Server (whichever is enabled first)
|
||||||
|
|
||||||
|
### Q: What if transcription fails?
|
||||||
|
**A:** Automatic fallback chain:
|
||||||
|
1. Browser Whisper (if enabled)
|
||||||
|
2. Falls back to Server (if configured)
|
||||||
|
3. Falls back to live transcript (if available)
|
||||||
|
|
||||||
|
### Q: Is Browser Whisper really offline?
|
||||||
|
**A:** Yes! Models are bundled in the Docker image. After the page loads once, transcription works with zero network access.
|
||||||
|
|
||||||
|
### Q: Does Web Speech work offline?
|
||||||
|
**A:** No. It requires internet to send audio to cloud servers.
|
||||||
|
|
||||||
|
### Q: Can I train/customize the models?
|
||||||
|
**A:** No. Browser Whisper uses pre-trained models. Server transcription uses cloud models. No custom training available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Browser Whisper stuck at "Initializing"
|
||||||
|
- **Cause:** Models not loaded or network blocked during initial download
|
||||||
|
- **Fix:** See [browser-whisper-troubleshooting.md](browser-whisper-troubleshooting.md)
|
||||||
|
|
||||||
|
### Server transcription returns "No provider"
|
||||||
|
- **Cause:** API keys not configured
|
||||||
|
- **Fix:** Set environment variables in `.env`
|
||||||
|
|
||||||
|
### Web Speech says "Not supported"
|
||||||
|
- **Cause:** Browser doesn't support Web Speech API
|
||||||
|
- **Fix:** Use Chrome, Edge, or Safari
|
||||||
|
|
||||||
|
### Transcription is slow
|
||||||
|
- **Browser Whisper:** Try switching to "Tiny" model
|
||||||
|
- **Server:** Check API provider status
|
||||||
|
- **Web Speech:** Check internet connection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Clinical Documentation
|
||||||
|
1. Use Browser Whisper for all patient data
|
||||||
|
2. Enable audio backups (automatic in v2)
|
||||||
|
3. Keep recordings under 5 minutes for faster processing
|
||||||
|
4. Use "Tiny" model for quick notes, "Base" for detailed documentation
|
||||||
|
|
||||||
|
### Personal Use
|
||||||
|
1. Web Speech for quick, informal notes
|
||||||
|
2. Browser Whisper for anything you want private
|
||||||
|
3. Server for long recordings
|
||||||
|
|
||||||
|
### Performance Optimization
|
||||||
|
1. Pre-download Browser Whisper model before first use
|
||||||
|
2. Use shorter clips (30-60 seconds) for fastest results
|
||||||
|
3. Clear browser cache if models seem corrupted
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Need | Recommendation |
|
||||||
|
|------|---------------|
|
||||||
|
| Clinical/HIPAA | Browser Whisper (offline) |
|
||||||
|
| Fast transcription | Server (Vertex AI) |
|
||||||
|
| Real-time feedback | Web Speech (non-clinical only) |
|
||||||
|
| Maximum privacy | Browser Whisper |
|
||||||
|
| Zero cost | Browser Whisper |
|
||||||
|
| Long recordings | Server (faster for 5+ min clips) |
|
||||||
|
| Offline use | Browser Whisper |
|
||||||
|
|
||||||
|
**Default recommendation:** Browser Whisper for 95% of use cases. It's private, accurate, free, and offline. Only use alternatives when you have specific needs for speed or real-time feedback.
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ const USE_REAL_AI = process.env.E2E_USE_REAL_AI === '1' || process.env.E2E_USE_R
|
||||||
// message matches one of these patterns it does NOT fail the test.
|
// message matches one of these patterns it does NOT fail the test.
|
||||||
const CONSOLE_ERROR_ALLOWLIST = [
|
const CONSOLE_ERROR_ALLOWLIST = [
|
||||||
/favicon/i,
|
/favicon/i,
|
||||||
|
/Failed to load resource.*models\/Xenova/i, // Browser Whisper models lazy-loaded on demand
|
||||||
/\/api\/models/i, // When no AI provider configured yet
|
/\/api\/models/i, // When no AI provider configured yet
|
||||||
/Cross-Origin-Opener-Policy/i, // Chrome warning on non-HTTPS e2e server
|
/Cross-Origin-Opener-Policy/i, // Chrome warning on non-HTTPS e2e server
|
||||||
/Failed to load resource.*(400|401|403|404|500|502|503)/i, // Any HTTP error on subsidiary fetches — smoke tests only verify UI renders, deeper integration tests validate endpoint contracts separately
|
/Failed to load resource.*(400|401|403|404|500|502|503)/i, // Any HTTP error on subsidiary fetches — smoke tests only verify UI renders, deeper integration tests validate endpoint contracts separately
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ test.describe('Unauthenticated auth screen', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('register link is present but currently disabled (display:none)', async ({ page }) => {
|
test('register link is present but currently disabled (display:none)', async ({ page }) => {
|
||||||
// Invite-only registration hides the link while keeping the form in the DOM.
|
// Daniel's instance has invite-only registration — the "Create account"
|
||||||
|
// link is explicitly hidden via inline style, so the HTML is there but
|
||||||
|
// users can't reach the register form through the UI. Verify the hidden
|
||||||
|
// state so flipping the style to re-enable it fails loudly.
|
||||||
await page.goto(E2E_BASE + '/');
|
await page.goto(E2E_BASE + '/');
|
||||||
await page.waitForSelector('#auth-screen', { timeout: 10000 });
|
await page.waitForSelector('#auth-screen', { timeout: 10000 });
|
||||||
const display = await page.locator('#show-register').evaluate(el => el.style.display);
|
const display = await page.locator('#show-register').evaluate(el => el.style.display);
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,14 @@
|
||||||
// SESSION PERSISTENCE — full logout → login → still on the same
|
// SESSION PERSISTENCE — full logout → login → still on the same
|
||||||
// tab + same sub-pill.
|
// tab + same sub-pill.
|
||||||
//
|
//
|
||||||
// The test does a programmatic logout (clear the ped_auth cookie,
|
// The UI's login form is gated by a Cloudflare Turnstile token
|
||||||
// same effect server-side as clicking Logout) followed by a fresh
|
// whose site key is hardcoded in index.html, which can't be
|
||||||
// programmatic login. This exercises the same localStorage
|
// completed in the e2e container (Turnstile rejects the non-prod
|
||||||
// persistence path a real logout/login would.
|
// origin). So the test does a programmatic logout (clear the
|
||||||
//
|
// ped_auth cookie, same effect server-side as clicking Logout)
|
||||||
// (Historically this was a workaround for the Turnstile challenge on
|
// followed by a fresh programmatic login — this exercises the
|
||||||
// the login form, which could not be completed in the e2e container.
|
// same localStorage persistence path a real logout/login would,
|
||||||
// Login is no longer gated, but driving it programmatically keeps
|
// without depending on the bot challenge.
|
||||||
// the test focused on persistence rather than form mechanics.)
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
const { test, expect, E2E_BASE, loginAs } = require('../fixtures');
|
const { test, expect, E2E_BASE, loginAs } = require('../fixtures');
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
// Adds soft-delete support for personal_notes via deleted_at.
|
/**
|
||||||
|
* Soft-delete for personal_notes — Daniel asked for "deleted notes go to
|
||||||
|
* trash" so a slip of the finger doesn't lose work. Adds a deleted_at
|
||||||
|
* timestamp; NULL means active. Trash listing filters by NOT NULL,
|
||||||
|
* regular listing filters by NULL.
|
||||||
|
*
|
||||||
|
* Restore = clear deleted_at. Empty Trash = real DELETE. No retention
|
||||||
|
* policy yet — items stay in trash until the user empties it.
|
||||||
|
*/
|
||||||
|
|
||||||
exports.up = (pgm) => {
|
exports.up = (pgm) => {
|
||||||
pgm.addColumn('personal_notes', {
|
pgm.addColumn('personal_notes', {
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
/**
|
|
||||||
* Mermaid Diagrams — per-user clinical pathway / algorithm diagrams.
|
|
||||||
* Source is plain Mermaid text; rendered to SVG client-side. Source
|
|
||||||
* encrypted at rest like personal_notes so a row dump stays useless
|
|
||||||
* without the app key.
|
|
||||||
*/
|
|
||||||
|
|
||||||
exports.up = (pgm) => {
|
|
||||||
pgm.createTable('mermaid_diagrams', {
|
|
||||||
id: { type: 'serial', primaryKey: true },
|
|
||||||
user_id: { type: 'integer', notNull: true, references: 'users(id)', onDelete: 'CASCADE' },
|
|
||||||
title: { type: 'text', notNull: true },
|
|
||||||
source: { type: 'text', notNull: true, default: '' },
|
|
||||||
notes: { type: 'text', notNull: true, default: '' },
|
|
||||||
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
|
|
||||||
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
|
|
||||||
});
|
|
||||||
pgm.createIndex('mermaid_diagrams', 'user_id');
|
|
||||||
pgm.createIndex('mermaid_diagrams', ['user_id', 'updated_at']);
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.down = (pgm) => {
|
|
||||||
pgm.dropTable('mermaid_diagrams');
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
# PedScribe Mobile App
|
# PedScribe Mobile App
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Hosted web workflow inside a native WebView; server updates reach mobile clients without app-store releases
|
- Background recording that survives screen lock (foreground service on Android, background audio on iOS)
|
||||||
- Configurable server URL (supports self-hosted instances)
|
- Configurable server URL (supports self-hosted instances)
|
||||||
- Haptic feedback on recording start/stop
|
- Haptic feedback on recording start/stop
|
||||||
- Keep screen awake during recording
|
- Keep screen awake during recording
|
||||||
|
|
@ -15,7 +15,7 @@ Capacitor mobile wrapper for the hosted Ped-AI web app. The app defaults to `htt
|
||||||
stored in iOS Keychain / Android Keystore, gated by OS biometric.
|
stored in iOS Keychain / Android Keystore, gated by OS biometric.
|
||||||
Enrolled on first password sign-in (opt-in prompt). 2FA still applies
|
Enrolled on first password sign-in (opt-in prompt). 2FA still applies
|
||||||
on top — biometric replaces the password step only.
|
on top — biometric replaces the password step only.
|
||||||
- Android and iOS project scaffolds for store builds
|
- App Store and Play Store ready
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ android {
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
// Version values below are overwritten by scripts/release.sh from
|
// Version values below are overwritten by scripts/release.sh from
|
||||||
// the root package.json. versionCode auto-increments per release.
|
// the root package.json. versionCode auto-increments per release.
|
||||||
versionCode 714016
|
versionCode 707000
|
||||||
versionName "7.14.16"
|
versionName "7.7.0"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,11 @@
|
||||||
package com.pedshub.scribe;
|
package com.pedshub.scribe;
|
||||||
|
|
||||||
import android.Manifest;
|
import android.Manifest;
|
||||||
import android.content.ContentResolver;
|
|
||||||
import android.content.ContentValues;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Environment;
|
|
||||||
import android.print.PrintAttributes;
|
|
||||||
import android.print.PrintDocumentAdapter;
|
|
||||||
import android.print.PrintManager;
|
|
||||||
import android.provider.MediaStore;
|
|
||||||
import android.util.Base64;
|
|
||||||
import android.view.WindowManager;
|
|
||||||
import android.webkit.CookieManager;
|
|
||||||
import android.webkit.PermissionRequest;
|
import android.webkit.PermissionRequest;
|
||||||
import android.webkit.WebChromeClient;
|
import android.webkit.WebChromeClient;
|
||||||
import android.webkit.WebViewClient;
|
|
||||||
import android.webkit.WebView;
|
import android.webkit.WebView;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
@ -28,20 +14,10 @@ import androidx.core.content.ContextCompat;
|
||||||
|
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileOutputStream;
|
|
||||||
import java.io.OutputStream;
|
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {
|
public class MainActivity extends BridgeActivity {
|
||||||
|
|
||||||
private static final int MIC_PERMISSION_CODE = 1001;
|
private static final int MIC_PERMISSION_CODE = 1001;
|
||||||
private PermissionRequest pendingPermissionRequest;
|
private PermissionRequest pendingPermissionRequest;
|
||||||
private WebView printWebView;
|
|
||||||
|
|
||||||
// True between startForegroundService() and stopForegroundService(), i.e.
|
|
||||||
// while the web app has an active MediaRecorder. Drives the keep-screen-on
|
|
||||||
// flag and the timer-throttling workaround below.
|
|
||||||
private volatile boolean recordingActive = false;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
|
@ -54,93 +30,11 @@ public class MainActivity extends BridgeActivity {
|
||||||
new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE);
|
new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow the Cloudflare Turnstile iframe to use storage.
|
|
||||||
setupThirdPartyCookies();
|
|
||||||
|
|
||||||
// Setup WebView mic permission granting
|
// Setup WebView mic permission granting
|
||||||
setupWebViewPermissions();
|
setupWebViewPermissions();
|
||||||
|
|
||||||
// Register JS interface for foreground service control
|
// Register JS interface for foreground service control
|
||||||
setupRecordingBridge();
|
setupRecordingBridge();
|
||||||
|
|
||||||
// Register JS interface for Android's print / Save as PDF flow.
|
|
||||||
setupPrintBridge();
|
|
||||||
|
|
||||||
// Register JS interface for saving generated visuals to Photos.
|
|
||||||
setupFileBridge();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Recording Lifecycle ────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Recording happens in the WebView (MediaRecorder), not in native code,
|
|
||||||
// so keeping the foreground service alive is necessary but not sufficient
|
|
||||||
// — the WebView also has to keep executing JS. Two things protect that:
|
|
||||||
//
|
|
||||||
// 1. FLAG_KEEP_SCREEN_ON while recording, so the device does not
|
|
||||||
// auto-lock mid-encounter. This is the case that actually bites
|
|
||||||
// clinicians: a long pause in conversation and the screen times out.
|
|
||||||
//
|
|
||||||
// 2. resumeTimers() if the activity is paused anyway (user presses the
|
|
||||||
// power button, or a call comes in). Chromium throttles timers hard
|
|
||||||
// for hidden WebViews, which starves MediaRecorder's chunk delivery.
|
|
||||||
// Capacitor never calls webView.onPause(), so the WebView itself is
|
|
||||||
// still live — it is only the timers that need rescuing.
|
|
||||||
//
|
|
||||||
// Note resumeTimers()/pauseTimers() are process-global in WebView, not
|
|
||||||
// per-instance; calling resume here is safe because this app has no other
|
|
||||||
// WebView that wants throttling (printWebView is transient).
|
|
||||||
|
|
||||||
void setKeepScreenOn(final boolean on) {
|
|
||||||
runOnUiThread(() -> {
|
|
||||||
if (on) {
|
|
||||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
|
||||||
} else {
|
|
||||||
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void setRecordingActive(boolean active) {
|
|
||||||
recordingActive = active;
|
|
||||||
setKeepScreenOn(active);
|
|
||||||
}
|
|
||||||
|
|
||||||
// NB: BridgeActivity declares these public — narrowing to protected would
|
|
||||||
// not compile.
|
|
||||||
@Override
|
|
||||||
public void onPause() {
|
|
||||||
super.onPause();
|
|
||||||
if (recordingActive && this.bridge != null && this.bridge.getWebView() != null) {
|
|
||||||
this.bridge.getWebView().resumeTimers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onResume() {
|
|
||||||
super.onResume();
|
|
||||||
if (this.bridge != null && this.bridge.getWebView() != null) {
|
|
||||||
this.bridge.getWebView().resumeTimers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Third-Party Cookies ────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Android WebView blocks third-party cookies by default (unlike Chrome,
|
|
||||||
// which still allows them for now). Cloudflare Turnstile runs inside a
|
|
||||||
// cross-origin iframe from challenges.cloudflare.com and needs its own
|
|
||||||
// storage to run and persist a challenge — without this the widget
|
|
||||||
// silently stalls or errors and never emits a token, so registration and
|
|
||||||
// password reset are impossible from inside the app.
|
|
||||||
//
|
|
||||||
// This is scoped to our own WebView, which only ever loads the PedScribe
|
|
||||||
// origin (see allowNavigation in capacitor.config.json), so it is not a
|
|
||||||
// general relaxation of the app's cookie policy.
|
|
||||||
|
|
||||||
private void setupThirdPartyCookies() {
|
|
||||||
WebView webView = this.bridge.getWebView();
|
|
||||||
CookieManager cookieManager = CookieManager.getInstance();
|
|
||||||
cookieManager.setAcceptCookie(true);
|
|
||||||
cookieManager.setAcceptThirdPartyCookies(webView, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WebView Microphone Permission ──────────────────────────
|
// ── WebView Microphone Permission ──────────────────────────
|
||||||
|
|
@ -186,16 +80,6 @@ public class MainActivity extends BridgeActivity {
|
||||||
webView.addJavascriptInterface(new RecordingBridge(this), "NativeRecording");
|
webView.addJavascriptInterface(new RecordingBridge(this), "NativeRecording");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setupPrintBridge() {
|
|
||||||
WebView webView = this.bridge.getWebView();
|
|
||||||
webView.addJavascriptInterface(new PrintBridge(this), "NativePrint");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setupFileBridge() {
|
|
||||||
WebView webView = this.bridge.getWebView();
|
|
||||||
webView.addJavascriptInterface(new FileBridge(this), "NativeFiles");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class RecordingBridge {
|
public static class RecordingBridge {
|
||||||
private final MainActivity activity;
|
private final MainActivity activity;
|
||||||
|
|
||||||
|
|
@ -207,7 +91,6 @@ public class MainActivity extends BridgeActivity {
|
||||||
public void startForegroundService() {
|
public void startForegroundService() {
|
||||||
Intent intent = new Intent(activity, AudioRecordingService.class);
|
Intent intent = new Intent(activity, AudioRecordingService.class);
|
||||||
ContextCompat.startForegroundService(activity, intent);
|
ContextCompat.startForegroundService(activity, intent);
|
||||||
activity.setRecordingActive(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
|
|
@ -215,108 +98,6 @@ public class MainActivity extends BridgeActivity {
|
||||||
Intent intent = new Intent(activity, AudioRecordingService.class);
|
Intent intent = new Intent(activity, AudioRecordingService.class);
|
||||||
intent.setAction(AudioRecordingService.ACTION_STOP);
|
intent.setAction(AudioRecordingService.ACTION_STOP);
|
||||||
activity.startService(intent);
|
activity.startService(intent);
|
||||||
activity.setRecordingActive(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standalone keep-awake, exposed so the web app can hold the screen on
|
|
||||||
// for non-recording work too. window.nativeKeepAwake() previously
|
|
||||||
// called Capacitor's KeepAwake plugin, which is not installed in this
|
|
||||||
// project — so it silently did nothing and the screen slept during
|
|
||||||
// recordings.
|
|
||||||
@android.webkit.JavascriptInterface
|
|
||||||
public void keepAwake(boolean on) {
|
|
||||||
activity.setKeepScreenOn(on);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class PrintBridge {
|
|
||||||
private final MainActivity activity;
|
|
||||||
|
|
||||||
PrintBridge(MainActivity activity) {
|
|
||||||
this.activity = activity;
|
|
||||||
}
|
|
||||||
|
|
||||||
@android.webkit.JavascriptInterface
|
|
||||||
public void printHtml(String title, String base64Html) {
|
|
||||||
activity.runOnUiThread(() -> activity.printHtmlFromBase64(title, base64Html));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class FileBridge {
|
|
||||||
private final MainActivity activity;
|
|
||||||
|
|
||||||
FileBridge(MainActivity activity) {
|
|
||||||
this.activity = activity;
|
|
||||||
}
|
|
||||||
|
|
||||||
@android.webkit.JavascriptInterface
|
|
||||||
public String saveImage(String filename, String base64Png) {
|
|
||||||
return activity.saveImageToPictures(filename, base64Png);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void printHtmlFromBase64(String title, String base64Html) {
|
|
||||||
try {
|
|
||||||
byte[] decoded = Base64.decode(base64Html, Base64.DEFAULT);
|
|
||||||
String html = new String(decoded, java.nio.charset.StandardCharsets.UTF_8);
|
|
||||||
printWebView = new WebView(this);
|
|
||||||
printWebView.setWebViewClient(new WebViewClient() {
|
|
||||||
@Override
|
|
||||||
public void onPageFinished(WebView view, String url) {
|
|
||||||
PrintManager printManager = (PrintManager) getSystemService(Context.PRINT_SERVICE);
|
|
||||||
PrintDocumentAdapter adapter = view.createPrintDocumentAdapter(title != null && !title.isEmpty() ? title : "Clinical Assistant Export");
|
|
||||||
printManager.print(title != null && !title.isEmpty() ? title : "Clinical Assistant Export", adapter, new PrintAttributes.Builder().build());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
printWebView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null);
|
|
||||||
} catch (Exception e) {
|
|
||||||
android.util.Log.e("PedScribe", "Native print failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String saveImageToPictures(String filename, String base64Png) {
|
|
||||||
String safeName = sanitizeFilename(filename, "clinical-visual.png");
|
|
||||||
try {
|
|
||||||
byte[] imageBytes = Base64.decode(base64Png, Base64.DEFAULT);
|
|
||||||
Uri uri;
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
ContentResolver resolver = getContentResolver();
|
|
||||||
ContentValues values = new ContentValues();
|
|
||||||
values.put(MediaStore.Images.Media.DISPLAY_NAME, safeName);
|
|
||||||
values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
|
|
||||||
values.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/PedScribe");
|
|
||||||
values.put(MediaStore.Images.Media.IS_PENDING, 1);
|
|
||||||
uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
|
|
||||||
if (uri == null) return "error:Could not create image file";
|
|
||||||
try (OutputStream out = resolver.openOutputStream(uri)) {
|
|
||||||
if (out == null) return "error:Could not open image file";
|
|
||||||
out.write(imageBytes);
|
|
||||||
}
|
|
||||||
values.clear();
|
|
||||||
values.put(MediaStore.Images.Media.IS_PENDING, 0);
|
|
||||||
resolver.update(uri, values, null, null);
|
|
||||||
} else {
|
|
||||||
File dir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), "PedScribe");
|
|
||||||
if (!dir.exists() && !dir.mkdirs()) return "error:Could not create Pictures/PedScribe";
|
|
||||||
File file = new File(dir, safeName);
|
|
||||||
try (OutputStream out = new FileOutputStream(file)) {
|
|
||||||
out.write(imageBytes);
|
|
||||||
}
|
|
||||||
uri = Uri.fromFile(file);
|
|
||||||
sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri));
|
|
||||||
}
|
|
||||||
return "saved:" + uri.toString();
|
|
||||||
} catch (Exception e) {
|
|
||||||
android.util.Log.e("PedScribe", "Native image save failed", e);
|
|
||||||
return "error:" + (e.getMessage() != null ? e.getMessage() : "Image save failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String sanitizeFilename(String filename, String fallback) {
|
|
||||||
String value = filename != null ? filename : fallback;
|
|
||||||
value = value.replaceAll("[^A-Za-z0-9._-]", "-");
|
|
||||||
if (value.length() == 0) value = fallback;
|
|
||||||
if (!value.toLowerCase(java.util.Locale.US).endsWith(".png")) value = value + ".png";
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@color/colorPrimary</item>
|
<item name="android:background">@null</item>
|
||||||
<item name="android:statusBarColor">@color/colorPrimaryDark</item>
|
<item name="android:statusBarColor">@color/colorPrimaryDark</item>
|
||||||
<item name="android:navigationBarColor">@color/colorPrimaryDark</item>
|
<item name="android:navigationBarColor">@color/colorPrimaryDark</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -22,4 +22,4 @@
|
||||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
<item name="android:background">@drawable/splash</item>
|
<item name="android:background">@drawable/splash</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -2,6 +2,4 @@
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<external-path name="my_images" path="." />
|
<external-path name="my_images" path="." />
|
||||||
<cache-path name="my_cache_images" path="." />
|
<cache-path name="my_cache_images" path="." />
|
||||||
<files-path name="my_files" path="." />
|
</paths>
|
||||||
<external-files-path name="my_external_files" path="." />
|
|
||||||
</paths>
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
json_key_file('fastlane/google-play-service-account.json')
|
|
||||||
package_name('com.pedshub.scribe')
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
source 'https://rubygems.org'
|
|
||||||
|
|
||||||
gem 'fastlane'
|
|
||||||
26
mobile/package-lock.json
generated
26
mobile/package-lock.json
generated
|
|
@ -1,19 +1,18 @@
|
||||||
{
|
{
|
||||||
"name": "pedscribe-mobile",
|
"name": "pedscribe-mobile",
|
||||||
"version": "7.14.14",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pedscribe-mobile",
|
"name": "pedscribe-mobile",
|
||||||
"version": "7.14.14",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aparajita/capacitor-biometric-auth": "^8.0.0",
|
"@aparajita/capacitor-biometric-auth": "^8.0.0",
|
||||||
"@capacitor/android": "^6.0.0",
|
"@capacitor/android": "^6.0.0",
|
||||||
"@capacitor/app": "^6.0.0",
|
"@capacitor/app": "^6.0.0",
|
||||||
"@capacitor/cli": "^6.0.0",
|
"@capacitor/cli": "^6.0.0",
|
||||||
"@capacitor/core": "^6.0.0",
|
"@capacitor/core": "^6.0.0",
|
||||||
"@capacitor/filesystem": "^6.0.4",
|
|
||||||
"@capacitor/haptics": "^6.0.0",
|
"@capacitor/haptics": "^6.0.0",
|
||||||
"@capacitor/ios": "^6.0.0",
|
"@capacitor/ios": "^6.0.0",
|
||||||
"@capacitor/keyboard": "^6.0.0",
|
"@capacitor/keyboard": "^6.0.0",
|
||||||
|
|
@ -21,8 +20,7 @@
|
||||||
"@capacitor/screen-orientation": "^6.0.0",
|
"@capacitor/screen-orientation": "^6.0.0",
|
||||||
"@capacitor/share": "^6.0.0",
|
"@capacitor/share": "^6.0.0",
|
||||||
"@capacitor/splash-screen": "^6.0.0",
|
"@capacitor/splash-screen": "^6.0.0",
|
||||||
"@capacitor/status-bar": "^6.0.0",
|
"@capacitor/status-bar": "^6.0.0"
|
||||||
"capacitor-secure-storage-plugin": "^0.10.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aparajita/capacitor-biometric-auth": {
|
"node_modules/@aparajita/capacitor-biometric-auth": {
|
||||||
|
|
@ -99,15 +97,6 @@
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@capacitor/filesystem": {
|
|
||||||
"version": "6.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-6.0.4.tgz",
|
|
||||||
"integrity": "sha512-eFlg/ZrwYA4Y6ClLRRikudVu2XvuZxfX/XC0ky9MgfbC9dyqTnVkkEoWM6vr1xR89YNY4mB0EeVTet1m1Jcumw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@capacitor/core": "^6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@capacitor/haptics": {
|
"node_modules/@capacitor/haptics": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-6.0.3.tgz",
|
||||||
|
|
@ -499,15 +488,6 @@
|
||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/capacitor-secure-storage-plugin": {
|
|
||||||
"version": "0.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/capacitor-secure-storage-plugin/-/capacitor-secure-storage-plugin-0.10.0.tgz",
|
|
||||||
"integrity": "sha512-dV4E+HTZAJWC3gef7sBXaAkkb6wvcZHyXjJIHXNb3yz9gRQ/5VMLqCxa0khqpwgWh5oIbo4XFxg3g5tEkfaNMg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@capacitor/core": "^6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chownr": {
|
"node_modules/chownr": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "pedscribe-mobile",
|
"name": "pedscribe-mobile",
|
||||||
"version": "7.14.16",
|
"version": "7.7.0",
|
||||||
"description": "PedScribe native mobile app — Capacitor wrapper for Pediatric AI Scribe",
|
"description": "PedScribe native mobile app — Capacitor wrapper for Pediatric AI Scribe",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -11,20 +11,19 @@
|
||||||
"build:ios": "npx cap sync ios"
|
"build:ios": "npx cap sync ios"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aparajita/capacitor-biometric-auth": "^8.0.0",
|
|
||||||
"@capacitor/android": "^6.0.0",
|
"@capacitor/android": "^6.0.0",
|
||||||
"@capacitor/app": "^6.0.0",
|
"@capacitor/app": "^6.0.0",
|
||||||
"@capacitor/cli": "^6.0.0",
|
"@capacitor/cli": "^6.0.0",
|
||||||
"@capacitor/core": "^6.0.0",
|
"@capacitor/core": "^6.0.0",
|
||||||
"@capacitor/filesystem": "^6.0.4",
|
|
||||||
"@capacitor/haptics": "^6.0.0",
|
|
||||||
"@capacitor/ios": "^6.0.0",
|
"@capacitor/ios": "^6.0.0",
|
||||||
|
"@capacitor/haptics": "^6.0.0",
|
||||||
"@capacitor/keyboard": "^6.0.0",
|
"@capacitor/keyboard": "^6.0.0",
|
||||||
"@capacitor/push-notifications": "^6.0.0",
|
"@capacitor/push-notifications": "^6.0.0",
|
||||||
"@capacitor/screen-orientation": "^6.0.0",
|
"@capacitor/screen-orientation": "^6.0.0",
|
||||||
"@capacitor/share": "^6.0.0",
|
"@capacitor/share": "^6.0.0",
|
||||||
"@capacitor/splash-screen": "^6.0.0",
|
"@capacitor/splash-screen": "^6.0.0",
|
||||||
"@capacitor/status-bar": "^6.0.0",
|
"@capacitor/status-bar": "^6.0.0",
|
||||||
|
"capacitor-native-biometric": "^5.0.0",
|
||||||
"capacitor-secure-storage-plugin": "^0.10.0"
|
"capacitor-secure-storage-plugin": "^0.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
144
package-lock.json
generated
144
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "pediatric-ai-scribe",
|
"name": "pediatric-ai-scribe",
|
||||||
"version": "7.14.14",
|
"version": "7.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pediatric-ai-scribe",
|
"name": "pediatric-ai-scribe",
|
||||||
"version": "7.14.14",
|
"version": "7.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@marp-team/marp-cli": "^4.3.1",
|
"@marp-team/marp-cli": "^4.3.1",
|
||||||
"@marp-team/marp-core": "^4.3.0",
|
"@marp-team/marp-core": "^4.3.0",
|
||||||
|
|
@ -28,7 +28,6 @@
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mammoth": "^1.8.0",
|
"mammoth": "^1.8.0",
|
||||||
"markdown-it": "^14.1.1",
|
|
||||||
"marked": "^18.0.2",
|
"marked": "^18.0.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-pg-migrate": "^7.7.0",
|
"node-pg-migrate": "^7.7.0",
|
||||||
|
|
@ -38,9 +37,7 @@
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^1.1.1",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
"pptxgenjs": "^4.0.1",
|
"pptxgenjs": "^4.0.1",
|
||||||
"prom-client": "^15.1.3",
|
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"redis": "^4.7.1",
|
|
||||||
"speakeasy": "^2.0.0"
|
"speakeasy": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -2280,15 +2277,6 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opentelemetry/api": {
|
|
||||||
"version": "1.9.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
|
|
||||||
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@phc/format": {
|
"node_modules/@phc/format": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
||||||
|
|
@ -2483,65 +2471,6 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@redis/bloom": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@redis/client": {
|
|
||||||
"version": "1.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
|
||||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cluster-key-slot": "1.1.2",
|
|
||||||
"generic-pool": "3.9.0",
|
|
||||||
"yallist": "4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@redis/graph": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@redis/json": {
|
|
||||||
"version": "1.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
|
|
||||||
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@redis/search": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@redis/time-series": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@remirror/core-constants": {
|
"node_modules/@remirror/core-constants": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||||
|
|
@ -4171,12 +4100,6 @@
|
||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bintrees": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/bluebird": {
|
"node_modules/bluebird": {
|
||||||
"version": "3.4.7",
|
"version": "3.4.7",
|
||||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
|
||||||
|
|
@ -4353,15 +4276,6 @@
|
||||||
"wrap-ansi": "^6.2.0"
|
"wrap-ansi": "^6.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cluster-key-slot": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
|
@ -5408,15 +5322,6 @@
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/generic-pool": {
|
|
||||||
"version": "3.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
|
|
||||||
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/get-caller-file": {
|
"node_modules/get-caller-file": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
|
@ -7391,19 +7296,6 @@
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prom-client": {
|
|
||||||
"version": "15.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz",
|
|
||||||
"integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@opentelemetry/api": "^1.4.0",
|
|
||||||
"tdigest": "^0.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^16 || ^18 || >=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prosemirror-changeset": {
|
"node_modules/prosemirror-changeset": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
|
||||||
|
|
@ -7854,23 +7746,6 @@
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/redis": {
|
|
||||||
"version": "4.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
|
|
||||||
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"./packages/*"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"@redis/bloom": "1.2.0",
|
|
||||||
"@redis/client": "1.6.1",
|
|
||||||
"@redis/graph": "1.1.1",
|
|
||||||
"@redis/json": "1.0.7",
|
|
||||||
"@redis/search": "1.2.0",
|
|
||||||
"@redis/time-series": "1.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
|
@ -8498,15 +8373,6 @@
|
||||||
"streamx": "^2.15.0"
|
"streamx": "^2.15.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tdigest": {
|
|
||||||
"version": "0.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz",
|
|
||||||
"integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bintrees": "1.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/teex": {
|
"node_modules/teex": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
|
||||||
|
|
@ -8879,12 +8745,6 @@
|
||||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yallist": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
"version": "15.4.1",
|
"version": "15.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "pediatric-ai-scribe",
|
"name": "pediatric-ai-scribe",
|
||||||
"version": "7.14.16",
|
"version": "7.7.0",
|
||||||
"description": "AI-powered pediatric clinical documentation platform",
|
"description": "AI-powered pediatric clinical documentation platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -36,7 +36,6 @@
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mammoth": "^1.8.0",
|
"mammoth": "^1.8.0",
|
||||||
"markdown-it": "^14.1.1",
|
|
||||||
"marked": "^18.0.2",
|
"marked": "^18.0.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-pg-migrate": "^7.7.0",
|
"node-pg-migrate": "^7.7.0",
|
||||||
|
|
@ -46,9 +45,7 @@
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^1.1.1",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
"pptxgenjs": "^4.0.1",
|
"pptxgenjs": "^4.0.1",
|
||||||
"prom-client": "^15.1.3",
|
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"redis": "^4.7.1",
|
|
||||||
"speakeasy": "^2.0.0"
|
"speakeasy": "^2.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -271,7 +271,7 @@
|
||||||
<div style="border-top:1px solid var(--g100);padding-top:14px;">
|
<div style="border-top:1px solid var(--g100);padding-top:14px;">
|
||||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Discover Models from Provider API</label>
|
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Discover Models from Provider API</label>
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||||
<input type="text" id="admin-model-search" placeholder="Search models (e.g. gemini, gpt, llama)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:200px;">
|
<input type="text" id="admin-model-search" placeholder="Search models (e.g. gemini, vendor-model, gpt)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:200px;">
|
||||||
<button id="btn-discover-models" class="btn-sm btn-primary"><i class="fas fa-magnifying-glass"></i> Search API</button>
|
<button id="btn-discover-models" class="btn-sm btn-primary"><i class="fas fa-magnifying-glass"></i> Search API</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="admin-discovered-models" style="margin-top:10px;display:flex;flex-direction:column;gap:4px;max-height:400px;overflow-y:auto;">
|
<div id="admin-discovered-models" style="margin-top:10px;display:flex;flex-direction:column;gap:4px;max-height:400px;overflow-y:auto;">
|
||||||
|
|
@ -318,17 +318,6 @@
|
||||||
<button id="btn-test-assistant-chat-model" class="btn-sm btn-primary" type="button"><i class="fas fa-vial"></i> Test</button>
|
<button id="btn-test-assistant-chat-model" class="btn-sm btn-primary" type="button"><i class="fas fa-vial"></i> Test</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="assistant-chat-test-result" style="font-size:12px;color:var(--g500);"></div>
|
<div id="assistant-chat-test-result" style="font-size:12px;color:var(--g500);"></div>
|
||||||
<div style="border:1px solid var(--g100);border-radius:8px;padding:10px;display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;">
|
|
||||||
<div>
|
|
||||||
<div style="font-size:13px;font-weight:600;color:var(--g700);">Starter prompt pool</div>
|
|
||||||
<div id="assistant-prompt-pool-status" style="font-size:12px;color:var(--g500);margin-top:3px;">Checking prompt pool...</div>
|
|
||||||
</div>
|
|
||||||
<button id="btn-regenerate-assistant-prompt-pool" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate"></i> Regenerate Pool</button>
|
|
||||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;width:100%;">
|
|
||||||
<select id="assistant-prompt-pool-snapshots" style="font-size:12px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:220px;"><option value="">Loading snapshots...</option></select>
|
|
||||||
<button id="btn-restore-assistant-prompt-pool" class="btn-sm btn-ghost" type="button"><i class="fas fa-clock-rotate-left"></i> Restore Snapshot</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||||
<label style="font-size:13px;font-weight:600;min-width:130px;">Image model:</label>
|
<label style="font-size:13px;font-weight:600;min-width:130px;">Image model:</label>
|
||||||
<select id="assistant-image-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;"></select>
|
<select id="assistant-image-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;"></select>
|
||||||
|
|
@ -336,11 +325,6 @@
|
||||||
<button id="btn-test-assistant-image-model" class="btn-sm btn-primary" type="button"><i class="fas fa-image"></i> Test</button>
|
<button id="btn-test-assistant-image-model" class="btn-sm btn-primary" type="button"><i class="fas fa-image"></i> Test</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="assistant-image-test-result" style="font-size:12px;color:var(--g500);"></div>
|
<div id="assistant-image-test-result" style="font-size:12px;color:var(--g500);"></div>
|
||||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:-6px;">
|
|
||||||
<label style="font-size:12px;font-weight:600;color:var(--g600);min-width:130px;">Custom image model</label>
|
|
||||||
<input id="assistant-custom-image-model" type="text" placeholder="e.g. openrouter-gpt-5-image" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;">
|
|
||||||
<button id="btn-use-custom-assistant-image-model" class="btn-sm btn-ghost" type="button"><i class="fas fa-plus"></i> Use Custom</button>
|
|
||||||
</div>
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Search result limit</label>
|
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Search result limit</label>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@
|
||||||
<textarea id="assistant-input" rows="3" placeholder="Ask a focused clinical question..." autocomplete="off"></textarea>
|
<textarea id="assistant-input" rows="3" placeholder="Ask a focused clinical question..." autocomplete="off"></textarea>
|
||||||
<div class="assistant-composer-footer">
|
<div class="assistant-composer-footer">
|
||||||
<label class="assistant-check"><input type="checkbox" id="assistant-include-context" checked> retrieve broader context</label>
|
<label class="assistant-check"><input type="checkbox" id="assistant-include-context" checked> retrieve broader context</label>
|
||||||
<button id="btn-assistant-cancel" class="btn-sm btn-ghost" type="button" hidden><i class="fas fa-stop"></i> Cancel search</button>
|
|
||||||
<button id="btn-assistant-send" class="btn-generate" type="submit"><i class="fas fa-paper-plane"></i> Ask</button>
|
<button id="btn-assistant-send" class="btn-generate" type="submit"><i class="fas fa-paper-plane"></i> Ask</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -91,11 +90,10 @@
|
||||||
.assistant-status.busy .assistant-dot { background:var(--amber); animation:pulse 1.5s infinite; }
|
.assistant-status.busy .assistant-dot { background:var(--amber); animation:pulse 1.5s infinite; }
|
||||||
.assistant-status.error .assistant-dot { background:var(--red); }
|
.assistant-status.error .assistant-dot { background:var(--red); }
|
||||||
.assistant-layout { display:grid; grid-template-columns:minmax(0,1fr) 330px; gap:14px; align-items:start; }
|
.assistant-layout { display:grid; grid-template-columns:minmax(0,1fr) 330px; gap:14px; align-items:start; }
|
||||||
.assistant-layout > * { min-width:0; }
|
.assistant-main { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 190px); }
|
||||||
.assistant-main { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 190px); min-width:0; }
|
|
||||||
.assistant-toolbar { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--g200); background:var(--g50); }
|
.assistant-toolbar { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--g200); background:var(--g50); }
|
||||||
.assistant-toolbar-actions { display:flex; gap:6px; flex-wrap:wrap; }
|
.assistant-toolbar-actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||||||
.assistant-messages { padding:16px; overflow-y:auto; overflow-x:hidden; background:linear-gradient(180deg,#fff,var(--g50)); min-width:0; }
|
.assistant-messages { padding:16px; overflow-y:auto; background:linear-gradient(180deg,#fff,var(--g50)); }
|
||||||
.assistant-empty { max-width:680px; margin:50px auto; text-align:center; color:var(--g500); }
|
.assistant-empty { max-width:680px; margin:50px auto; text-align:center; color:var(--g500); }
|
||||||
.assistant-empty i { font-size:34px; color:var(--purple); margin-bottom:10px; }
|
.assistant-empty i { font-size:34px; color:var(--purple); margin-bottom:10px; }
|
||||||
.assistant-empty h3 { color:var(--g800); font-size:18px; margin-bottom:6px; }
|
.assistant-empty h3 { color:var(--g800); font-size:18px; margin-bottom:6px; }
|
||||||
|
|
@ -104,10 +102,10 @@
|
||||||
.assistant-suggestion-buttons { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
|
.assistant-suggestion-buttons { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
|
||||||
.assistant-suggestion-buttons button { border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; text-align:left; }
|
.assistant-suggestion-buttons button { border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; text-align:left; }
|
||||||
.assistant-suggestion-buttons button:hover { border-color:var(--purple); background:var(--purple-light); }
|
.assistant-suggestion-buttons button:hover { border-color:var(--purple); background:var(--purple-light); }
|
||||||
.assistant-msg { max-width:900px; min-width:0; margin:0 0 14px; display:grid; gap:6px; }
|
.assistant-msg { max-width:900px; margin:0 0 14px; display:grid; gap:6px; }
|
||||||
.assistant-msg.user { margin-left:auto; max-width:760px; }
|
.assistant-msg.user { margin-left:auto; max-width:760px; }
|
||||||
.assistant-msg-label { font-size:11px; font-weight:700; color:var(--g400); text-transform:uppercase; letter-spacing:.04em; }
|
.assistant-msg-label { font-size:11px; font-weight:700; color:var(--g400); text-transform:uppercase; letter-spacing:.04em; }
|
||||||
.assistant-bubble { border:1px solid var(--g200); border-radius:14px; padding:12px 14px; background:white; box-shadow:var(--shadow); font-size:13px; line-height:1.75; overflow-wrap:anywhere; min-width:0; max-width:100%; }
|
.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-msg.user .assistant-bubble { background:var(--blue); color:white; border-color:var(--blue); }
|
.assistant-msg.user .assistant-bubble { background:var(--blue); color:white; border-color:var(--blue); }
|
||||||
.assistant-bubble h1, .assistant-bubble h2, .assistant-bubble h3 { margin:16px 0 8px; line-height:1.25; color:var(--g900); }
|
.assistant-bubble h1, .assistant-bubble h2, .assistant-bubble h3 { margin:16px 0 8px; line-height:1.25; color:var(--g900); }
|
||||||
.assistant-bubble h1:first-child, .assistant-bubble h2:first-child, .assistant-bubble h3:first-child { margin-top:0; }
|
.assistant-bubble h1:first-child, .assistant-bubble h2:first-child, .assistant-bubble h3:first-child { margin-top:0; }
|
||||||
|
|
@ -119,11 +117,8 @@
|
||||||
.assistant-bubble ul, .assistant-bubble ol { padding-left:20px; margin:8px 0; }
|
.assistant-bubble ul, .assistant-bubble ol { padding-left:20px; margin:8px 0; }
|
||||||
.assistant-bubble li { margin:4px 0; }
|
.assistant-bubble li { margin:4px 0; }
|
||||||
.assistant-bubble blockquote { margin:10px 0; padding:8px 12px; border-left:3px solid var(--blue); background:var(--blue-light); color:var(--g700); border-radius:8px; }
|
.assistant-bubble blockquote { margin:10px 0; padding:8px 12px; border-left:3px solid var(--blue); background:var(--blue-light); color:var(--g700); border-radius:8px; }
|
||||||
.assistant-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-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 table { width:max-content; min-width:100%; max-width:none; border-collapse:separate; border-spacing:0; margin:0; border:0; border-radius:0; font-size:12px; }
|
|
||||||
.assistant-table-scroll::after { content:'Swipe table'; display:none; position:sticky; left:0; bottom:0; padding:3px 9px; font-size:10px; font-weight:700; color:var(--g500); background:linear-gradient(90deg,rgba(255,255,255,.95),rgba(255,255,255,0)); pointer-events:none; }
|
|
||||||
.assistant-bubble th, .assistant-bubble td { padding:8px 10px; border-bottom:1px solid var(--g200); vertical-align:top; text-align:left; }
|
.assistant-bubble th, .assistant-bubble td { padding:8px 10px; border-bottom:1px solid var(--g200); vertical-align:top; text-align:left; }
|
||||||
.assistant-bubble th, .assistant-bubble td { overflow-wrap:normal; word-break:normal; min-width:120px; }
|
|
||||||
.assistant-bubble th { background:var(--g50); font-weight:700; color:var(--g800); }
|
.assistant-bubble th { background:var(--g50); font-weight:700; color:var(--g800); }
|
||||||
.assistant-bubble tr:last-child td { border-bottom:0; }
|
.assistant-bubble tr:last-child td { border-bottom:0; }
|
||||||
.assistant-bubble code { background:var(--g100); border-radius:4px; padding:1px 4px; }
|
.assistant-bubble code { background:var(--g100); border-radius:4px; padding:1px 4px; }
|
||||||
|
|
@ -137,15 +132,12 @@
|
||||||
.assistant-thinking-dot:nth-child(3) { animation-delay:.3s; margin-right:3px; }
|
.assistant-thinking-dot:nth-child(3) { animation-delay:.3s; margin-right:3px; }
|
||||||
@keyframes assistantBounce { 0%,80%,100% { transform:scale(.65); opacity:.45; } 40% { transform:scale(1); opacity:1; } }
|
@keyframes assistantBounce { 0%,80%,100% { transform:scale(.65); opacity:.45; } 40% { transform:scale(1); opacity:1; } }
|
||||||
@keyframes assistantShimmer { 0% { background-position:100% 0; } 100% { background-position:-100% 0; } }
|
@keyframes assistantShimmer { 0% { background-position:100% 0; } 100% { background-position:-100% 0; } }
|
||||||
.assistant-cite { display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 6px; margin:0 1px; border-radius:999px; background:var(--purple-light); color:var(--purple); font-size:10px; font-weight:800; text-decoration:none; vertical-align:baseline; border:1px solid rgba(124,58,237,.18); text-transform:uppercase; letter-spacing:.03em; }
|
.assistant-cite { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 6px; border-radius:999px; background:var(--purple-light); color:var(--purple); font-size:11px; font-weight:700; text-decoration:none; }
|
||||||
.assistant-cite:hover { background:var(--purple); color:white; text-decoration:none; }
|
|
||||||
.assistant-composer { border-top:1px solid var(--g200); padding:12px; background:white; display:grid; gap:8px; }
|
.assistant-composer { border-top:1px solid var(--g200); padding:12px; background:white; display:grid; gap:8px; }
|
||||||
.assistant-composer textarea, .assistant-side textarea { width:100%; border:1.5px solid var(--g300); border-radius:10px; padding:10px 12px; resize:vertical; font-family:inherit; font-size:13px; outline:none; }
|
.assistant-composer textarea, .assistant-side textarea { width:100%; border:1.5px solid var(--g300); border-radius:10px; padding:10px 12px; resize:vertical; font-family:inherit; font-size:13px; outline:none; }
|
||||||
.assistant-composer textarea:focus, .assistant-side textarea:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); }
|
.assistant-composer textarea:focus, .assistant-side textarea:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); }
|
||||||
.assistant-composer-footer { display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
.assistant-composer-footer { display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||||
.assistant-composer-footer .btn-generate { width:auto; margin:0; padding:9px 18px; }
|
.assistant-composer-footer .btn-generate { width:auto; margin:0; padding:9px 18px; }
|
||||||
.assistant-composer-footer #btn-assistant-cancel[hidden] { display:none !important; }
|
|
||||||
.assistant-composer-footer #btn-assistant-cancel:not([hidden]) { display:inline-flex; }
|
|
||||||
.assistant-check { font-size:12px; color:var(--g500); display:flex; align-items:center; gap:6px; }
|
.assistant-check { font-size:12px; color:var(--g500); display:flex; align-items:center; gap:6px; }
|
||||||
.assistant-side { display:grid; gap:12px; }
|
.assistant-side { display:grid; gap:12px; }
|
||||||
.assistant-side-body { padding:12px; display:grid; gap:10px; font-size:13px; }
|
.assistant-side-body { padding:12px; display:grid; gap:10px; font-size:13px; }
|
||||||
|
|
@ -155,12 +147,10 @@
|
||||||
.assistant-generated-image { display:grid; gap:8px; }
|
.assistant-generated-image { display:grid; gap:8px; }
|
||||||
.assistant-generated-image img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
|
.assistant-generated-image img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
|
||||||
.assistant-image-actions { display:flex; gap:8px; flex-wrap:wrap; }
|
.assistant-image-actions { display:flex; gap:8px; flex-wrap:wrap; }
|
||||||
.assistant-image-preview-open { overflow:hidden; }
|
|
||||||
.assistant-image-modal { position:fixed; inset:0; z-index:9999; background:rgba(15,23,42,.82); display:flex; align-items:center; justify-content:center; padding:24px; }
|
.assistant-image-modal { position:fixed; inset:0; z-index:9999; background:rgba(15,23,42,.82); display:flex; align-items:center; justify-content:center; padding:24px; }
|
||||||
.assistant-image-modal-card { position:relative; display:grid; gap:10px; max-width:min(96vw,1200px); max-height:92vh; }
|
.assistant-image-modal-card { position:relative; max-width:min(96vw,1200px); max-height:92vh; }
|
||||||
.assistant-image-modal-card img { max-width:100%; max-height:92vh; border-radius:14px; background:white; box-shadow:0 24px 80px rgba(0,0,0,.35); }
|
.assistant-image-modal-card img { max-width:100%; max-height:92vh; border-radius:14px; background:white; box-shadow:0 24px 80px rgba(0,0,0,.35); }
|
||||||
.assistant-image-modal-close { position:absolute; top: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-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-cancel { justify-self:center; border:0; border-radius:999px; background:white; color:var(--g800); font-weight:700; padding:9px 14px; box-shadow:var(--shadow); cursor:pointer; }
|
|
||||||
.assistant-sources { padding:10px 12px; display:grid; gap:8px; max-height:520px; overflow-y:auto; }
|
.assistant-sources { padding:10px 12px; display:grid; gap:8px; max-height:520px; overflow-y:auto; }
|
||||||
.assistant-saved-chats { padding:10px 12px; display:grid; gap:8px; max-height:220px; overflow-y:auto; }
|
.assistant-saved-chats { padding:10px 12px; display:grid; gap:8px; max-height:220px; overflow-y:auto; }
|
||||||
.assistant-saved-chat { border:1px solid var(--g200); border-radius:10px; padding:8px; background:white; display:grid; gap:5px; }
|
.assistant-saved-chat { border:1px solid var(--g200); border-radius:10px; padding:8px; background:white; display:grid; gap:5px; }
|
||||||
|
|
@ -188,20 +178,4 @@
|
||||||
.assistant-muted { color:var(--g500); font-size:12px; line-height:1.6; }
|
.assistant-muted { color:var(--g500); font-size:12px; line-height:1.6; }
|
||||||
.assistant-mermaid { background:white; border:1px solid var(--g200); border-radius:10px; padding:10px; margin:10px 0; overflow:auto; }
|
.assistant-mermaid { background:white; border:1px solid var(--g200); border-radius:10px; padding:10px; margin:10px 0; overflow:auto; }
|
||||||
@media (max-width: 960px) { .assistant-layout { grid-template-columns:1fr; } .assistant-main { min-height:auto; grid-template-rows:auto minmax(320px,1fr) auto; } }
|
@media (max-width: 960px) { .assistant-layout { grid-template-columns:1fr; } .assistant-main { min-height:auto; grid-template-rows:auto minmax(320px,1fr) auto; } }
|
||||||
@media (max-width: 640px) {
|
|
||||||
.assistant-header { flex-direction:column; align-items:stretch; }
|
|
||||||
.assistant-status { align-self:flex-start; }
|
|
||||||
.assistant-toolbar { flex-direction:column; align-items:stretch; }
|
|
||||||
.assistant-toolbar-actions { display:grid; grid-template-columns:1fr 1fr; }
|
|
||||||
.assistant-toolbar-actions .btn-sm { width:100%; justify-content:center; }
|
|
||||||
.assistant-messages { padding:10px; }
|
|
||||||
.assistant-msg, .assistant-msg.user { max-width:100%; }
|
|
||||||
.assistant-bubble { font-size:13px; padding:11px 12px; }
|
|
||||||
.assistant-table-scroll::after { display:block; }
|
|
||||||
.assistant-composer { position:sticky; bottom:0; z-index:3; }
|
|
||||||
.assistant-composer-footer { flex-direction:column; align-items:stretch; }
|
|
||||||
.assistant-composer-footer .btn-generate { width:100%; }
|
|
||||||
.assistant-composer-footer #btn-assistant-cancel:not([hidden]) { width:100%; justify-content:center; }
|
|
||||||
.assistant-side { gap:10px; }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,235 +0,0 @@
|
||||||
<div class="module-header">
|
|
||||||
<h2><i class="fas fa-diagram-project" style="color:#0ea5e9;"></i> Diagrams</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="diagrams-layout" id="diagrams-layout">
|
|
||||||
|
|
||||||
<aside class="diagrams-sidebar">
|
|
||||||
<div class="diagrams-sidebar-head">
|
|
||||||
<button id="btn-diagram-new" class="btn-primary diagrams-new-btn" type="button">
|
|
||||||
<i class="fas fa-plus"></i> New diagram
|
|
||||||
</button>
|
|
||||||
<div class="diagrams-search">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
<input type="text" id="diagram-search" placeholder="Search" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="diagrams-list" class="diagrams-list">
|
|
||||||
<div class="diagrams-empty">Loading…</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section id="diagram-editor" class="diagram-editor">
|
|
||||||
<div class="diagram-toolbar">
|
|
||||||
<input type="text" id="diagram-title" class="diagram-title-input" placeholder="Diagram title" autocomplete="off">
|
|
||||||
<span id="diagram-status" class="diagram-status"></span>
|
|
||||||
<div class="diagram-toolbar-actions">
|
|
||||||
<button id="btn-diagram-export-svg" class="btn-sm" type="button" title="Export SVG">
|
|
||||||
<i class="fas fa-download"></i> SVG
|
|
||||||
</button>
|
|
||||||
<button id="btn-diagram-export-png" class="btn-sm" type="button" title="Export PNG">
|
|
||||||
<i class="fas fa-download"></i> PNG
|
|
||||||
</button>
|
|
||||||
<button id="btn-diagram-delete" class="btn-sm btn-diagram-delete" type="button" title="Delete diagram"
|
|
||||||
style="background:var(--red-light);color:var(--red);border:1px solid var(--red);">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="diagram-panes">
|
|
||||||
<div class="diagram-source-pane">
|
|
||||||
<textarea id="diagram-source" class="diagram-source" spellcheck="false"
|
|
||||||
placeholder="graph TD A[Start] --> B{Decision?} B -->|Yes| C[Action] B -->|No| D[End]"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="diagram-preview-pane">
|
|
||||||
<div id="diagram-preview" class="diagram-preview"></div>
|
|
||||||
<div id="diagram-error" class="diagram-error hidden"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.diagrams-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 280px minmax(0, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
height: calc(100vh - 200px);
|
|
||||||
min-height: 480px;
|
|
||||||
}
|
|
||||||
.diagrams-sidebar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--g50, #f8fafb);
|
|
||||||
border: 1px solid var(--g100, #e5ebef);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.diagrams-sidebar-head {
|
|
||||||
padding: 10px;
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
border-bottom: 1px solid var(--g100, #e5ebef);
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
.diagrams-new-btn { width: 100%; }
|
|
||||||
.diagrams-search {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.diagrams-search i {
|
|
||||||
position: absolute;
|
|
||||||
left: 10px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
color: var(--g400, #94a3b8);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.diagrams-search input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 7px 10px 7px 28px;
|
|
||||||
border: 1px solid var(--g200, #d5dfe6);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.diagrams-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
.diagrams-empty {
|
|
||||||
padding: 18px 12px;
|
|
||||||
color: var(--g400, #94a3b8);
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.diagram-row {
|
|
||||||
display: grid;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 9px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
.diagram-row:hover {
|
|
||||||
background: white;
|
|
||||||
border-color: var(--g200, #d5dfe6);
|
|
||||||
}
|
|
||||||
.diagram-row.active {
|
|
||||||
background: var(--blue-light, #e0f2fe);
|
|
||||||
border-color: var(--blue, #0ea5e9);
|
|
||||||
}
|
|
||||||
.diagram-row strong {
|
|
||||||
font-size: 13.5px;
|
|
||||||
color: var(--g800, #1e293b);
|
|
||||||
}
|
|
||||||
.diagram-row span {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--g400, #94a3b8);
|
|
||||||
}
|
|
||||||
.diagram-editor {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
|
||||||
background: white;
|
|
||||||
border: 1px solid var(--g100, #e5ebef);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.diagram-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px;
|
|
||||||
border-bottom: 1px solid var(--g100, #e5ebef);
|
|
||||||
background: var(--g50, #f8fafb);
|
|
||||||
}
|
|
||||||
.diagram-title-input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 7px 10px;
|
|
||||||
border: 1px solid var(--g200, #d5dfe6);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
.diagram-status {
|
|
||||||
font-size: 11.5px;
|
|
||||||
color: var(--g400, #94a3b8);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.diagram-status.saving { color: var(--amber, #d97706); }
|
|
||||||
.diagram-status.saved { color: var(--green, #16a34a); }
|
|
||||||
.diagram-status.error { color: var(--red, #dc2626); }
|
|
||||||
.diagram-toolbar-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.diagram-panes {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.diagram-source-pane {
|
|
||||||
border-right: 1px solid var(--g100, #e5ebef);
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
.diagram-source {
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: 0;
|
|
||||||
padding: 12px;
|
|
||||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.55;
|
|
||||||
resize: none;
|
|
||||||
outline: none;
|
|
||||||
background: white;
|
|
||||||
color: var(--g800, #1e293b);
|
|
||||||
}
|
|
||||||
.diagram-preview-pane {
|
|
||||||
position: relative;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 18px;
|
|
||||||
background: var(--g50, #f8fafb);
|
|
||||||
}
|
|
||||||
.diagram-preview {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.diagram-preview svg {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
.diagram-error {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: var(--red-light, #fef2f2);
|
|
||||||
border: 1px solid var(--red, #dc2626);
|
|
||||||
color: var(--red, #dc2626);
|
|
||||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.diagrams-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
.diagrams-sidebar {
|
|
||||||
max-height: 280px;
|
|
||||||
}
|
|
||||||
.diagram-panes {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: 320px minmax(280px, 1fr);
|
|
||||||
}
|
|
||||||
.diagram-source-pane {
|
|
||||||
border-right: 0;
|
|
||||||
border-bottom: 1px solid var(--g100, #e5ebef);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -11,26 +11,9 @@
|
||||||
style="width:100%;padding:8px 10px 8px 32px;font-size:14px;border:1px solid var(--g300);border-radius:8px;">
|
style="width:100%;padding:8px 10px 8px 32px;font-size:14px;border:1px solid var(--g300);border-radius:8px;">
|
||||||
</div>
|
</div>
|
||||||
<button id="ext-add-btn" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add</button>
|
<button id="ext-add-btn" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add</button>
|
||||||
<button id="ext-export-btn" class="btn-sm btn-ghost"><i class="fas fa-file-export"></i> Export</button>
|
|
||||||
<button id="ext-import-btn" class="btn-sm btn-ghost"><i class="fas fa-file-import"></i> Import</button>
|
|
||||||
<input type="file" id="ext-import-file" accept="application/zip,application/json,.zip,.json" class="hidden">
|
|
||||||
<button id="ext-trash-btn" class="btn-sm btn-ghost"><i class="fas fa-trash-can"></i> Trash <span id="ext-trash-count" style="color:var(--g500);font-size:11px;"></span></button>
|
<button id="ext-trash-btn" class="btn-sm btn-ghost"><i class="fas fa-trash-can"></i> Trash <span id="ext-trash-count" style="color:var(--g500);font-size:11px;"></span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="ext-import-preview" class="hidden" style="margin:0 16px 14px;padding:12px;border:1px solid var(--g200);border-radius:10px;background:var(--g50);">
|
|
||||||
<div id="ext-import-preview-text" style="font-size:13px;color:var(--g700);line-height:1.5;margin-bottom:10px;"></div>
|
|
||||||
<label style="display:flex;gap:8px;align-items:center;font-size:12px;color:var(--g700);margin-bottom:6px;">
|
|
||||||
<input type="checkbox" id="ext-import-restore-trashed"> Restore exact matches currently in trash
|
|
||||||
</label>
|
|
||||||
<label style="display:flex;gap:8px;align-items:center;font-size:12px;color:var(--g700);margin-bottom:10px;">
|
|
||||||
<input type="checkbox" id="ext-import-possible"> Import possible duplicates instead of skipping them
|
|
||||||
</label>
|
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
|
||||||
<button id="ext-import-confirm" class="btn-sm btn-primary"><i class="fas fa-file-import"></i> Import Selected</button>
|
|
||||||
<button id="ext-import-cancel" class="btn-sm btn-ghost">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="ext-form-wrap" class="hidden" style="border-top:1px solid var(--g100);padding:14px 16px;background:var(--g50);">
|
<div id="ext-form-wrap" class="hidden" style="border-top:1px solid var(--g100);padding:14px 16px;background:var(--g50);">
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;">
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;">
|
||||||
<div class="demo-field">
|
<div class="demo-field">
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,14 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-question">What is Browser Whisper?</button>
|
||||||
|
<div class="faq-answer">
|
||||||
|
<p>Browser Whisper runs the Whisper AI model entirely in your browser using WebAssembly. Your audio never leaves your device, making it the most private transcription option. You can enable it in <strong>Settings > Browser Whisper</strong>.</p>
|
||||||
|
<p>It works offline and is HIPAA-safe since no data is transmitted. The tradeoff is that it is slower than cloud-based transcription and requires downloading the model (~40–240 MB) on first use.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="faq-item">
|
<div class="faq-item">
|
||||||
<button class="faq-question">Can I use the app on my phone?</button>
|
<button class="faq-question">Can I use the app on my phone?</button>
|
||||||
<div class="faq-answer">
|
<div class="faq-answer">
|
||||||
|
|
@ -168,7 +176,7 @@
|
||||||
<li>No patient data is stored long-term on the server</li>
|
<li>No patient data is stored long-term on the server</li>
|
||||||
<li>Every action is audit-logged (who accessed what, when)</li>
|
<li>Every action is audit-logged (who accessed what, when)</li>
|
||||||
<li>Two-factor authentication (2FA) and session management are available</li>
|
<li>Two-factor authentication (2FA) and session management are available</li>
|
||||||
<li>Server transcription can be configured with HIPAA-eligible providers</li>
|
<li>Browser Whisper keeps audio entirely on your device</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>For HIPAA compliance, ensure your administrator has configured a BAA-covered AI provider (such as AWS Bedrock, Google Vertex AI, or Azure OpenAI).</p>
|
<p>For HIPAA compliance, ensure your administrator has configured a BAA-covered AI provider (such as AWS Bedrock, Google Vertex AI, or Azure OpenAI).</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -335,3 +343,4 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,33 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Browser Whisper -->
|
||||||
|
<div class="settings-section card" id="browser-whisper-section">
|
||||||
|
<h3><i class="fas fa-microchip"></i> Browser Transcription (Local Whisper)</h3>
|
||||||
|
<p style="font-size:13px;color:var(--g600);">Transcribes audio entirely in your browser — no audio sent to any server. Powered by OpenAI Whisper running in WebAssembly. Model is downloaded once and cached locally.</p>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||||
|
<label style="font-size:13px;font-weight:600;">Enable browser transcription:</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||||
|
<input type="checkbox" id="browser-whisper-enabled" style="accent-color:var(--blue);width:16px;height:16px;">
|
||||||
|
<span style="font-size:13px;" id="browser-whisper-status">Off</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="browser-whisper-model-row" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||||
|
<label style="font-size:13px;font-weight:600;">Model:</label>
|
||||||
|
<select id="browser-whisper-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||||
|
<option value="Xenova/whisper-tiny.en">Tiny (~39MB) — fastest, ~2-3s</option>
|
||||||
|
<option value="Xenova/whisper-base.en">Base (~74MB) — balanced, ~3-5s</option>
|
||||||
|
<option value="Xenova/whisper-small.en">Small (~244MB) — best quality, ~6-10s</option>
|
||||||
|
</select>
|
||||||
|
<button id="btn-whisper-preload" class="btn-sm btn-ghost"><i class="fas fa-download"></i> Pre-download model</button>
|
||||||
|
</div>
|
||||||
|
<div id="browser-whisper-progress" style="display:none;font-size:12px;color:var(--g500);margin-top:4px;">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i> <span id="browser-whisper-progress-text">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px;color:var(--g400);margin:8px 0 0;"><i class="fas fa-info-circle"></i> When enabled, overrides server transcription. Falls back to server if browser transcription fails.</p>
|
||||||
|
<p style="font-size:11px;color:var(--orange);margin:4px 0 0;display:none;" id="browser-whisper-csp-warning"><i class="fas fa-exclamation-triangle"></i> <strong>Network/Firewall Issue:</strong> If model download fails, check that <code>cdn.jsdelivr.net</code> and <code>huggingface.co</code> are not blocked. Server transcription will be used as fallback.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Web Speech Recognition (Real-time Streaming) -->
|
<!-- Web Speech Recognition (Real-time Streaming) -->
|
||||||
<div class="settings-section card" id="web-speech-section" style="border-left:3px solid var(--orange);">
|
<div class="settings-section card" id="web-speech-section" style="border-left:3px solid var(--orange);">
|
||||||
<h3><i class="fas fa-wave-square"></i> Real-Time Streaming Transcription</h3>
|
<h3><i class="fas fa-wave-square"></i> Real-Time Streaming Transcription</h3>
|
||||||
|
|
@ -55,7 +82,7 @@
|
||||||
<p style="margin:0;" id="web-speech-browser-info">Detecting...</p>
|
<p style="margin:0;" id="web-speech-browser-info">Detecting...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="font-size:11px;color:var(--g400);margin:8px 0 0;"><i class="fas fa-info-circle"></i> <strong>Trade-off:</strong> Immediate transcription vs. privacy. For maximum privacy, use server transcription with a HIPAA-eligible provider.</p>
|
<p style="font-size:11px;color:var(--g400);margin:8px 0 0;"><i class="fas fa-info-circle"></i> <strong>Trade-off:</strong> Immediate transcription vs. privacy. For maximum privacy, use Browser Whisper (offline batch mode) or Server transcription with HIPAA-eligible provider.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Change Password — hidden by default; unhidden only for users with a real password -->
|
<!-- Change Password — hidden by default; unhidden only for users with a real password -->
|
||||||
|
|
@ -151,7 +178,7 @@
|
||||||
<!-- My Templates / Memories -->
|
<!-- My Templates / Memories -->
|
||||||
<div class="settings-section card">
|
<div class="settings-section card">
|
||||||
<h3><i class="fas fa-book-medical"></i> My Templates</h3>
|
<h3><i class="fas fa-book-medical"></i> My Templates</h3>
|
||||||
<p style="font-size:13px;color:var(--g600);">Save reusable templates for physical exam, ROS, encounter format, etc. Only template categories are sent to AI when generating notes. You can reference them by saying "use my normal physical exam" in dictation.</p>
|
<p style="font-size:13px;color:var(--g600);">Save reusable templates for physical exam, ROS, encounter format, etc. The AI will use these when generating notes. You can reference them by saying "use my normal physical exam" in dictation.</p>
|
||||||
<div style="margin-bottom:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
<div style="margin-bottom:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||||
<select id="mem-category" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;">
|
<select id="mem-category" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||||
<option value="physical_exam">Physical Exam Template</option>
|
<option value="physical_exam">Physical Exam Template</option>
|
||||||
|
|
@ -164,9 +191,9 @@
|
||||||
<option value="template_wellvisit">Well Visit Template</option>
|
<option value="template_wellvisit">Well Visit Template</option>
|
||||||
<option value="template_sickvisit">Sick Visit Template</option>
|
<option value="template_sickvisit">Sick Visit Template</option>
|
||||||
<option value="template_ed">ED Template</option>
|
<option value="template_ed">ED Template</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="text" id="mem-name" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:150px;" placeholder="Template name (e.g. Normal PE)">
|
<input type="text" id="mem-name" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:150px;" placeholder="Template name (e.g. Normal PE)">
|
||||||
<a href="/template-guide.md" download="ped-ai-template-guide.md" class="btn-sm btn-ghost" style="text-decoration:none;"><i class="fas fa-download"></i> Template Guide</a>
|
|
||||||
</div>
|
</div>
|
||||||
<textarea id="mem-content" rows="5" style="width:100%;font-size:12px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Paste your template here. Example: HEENT: Normocephalic, atraumatic. Eyes: PERRL. Ears: TMs clear. Throat: clear..."></textarea>
|
<textarea id="mem-content" rows="5" style="width:100%;font-size:12px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Paste your template here. Example: HEENT: Normocephalic, atraumatic. Eyes: PERRL. Ears: TMs clear. Throat: clear..."></textarea>
|
||||||
<div style="margin-top:8px;display:flex;gap:8px;">
|
<div style="margin-top:8px;display:flex;gap:8px;">
|
||||||
|
|
@ -215,6 +242,14 @@
|
||||||
<div class="settings-section card">
|
<div class="settings-section card">
|
||||||
<h3><i class="fas fa-shield-halved"></i> Compliance & Usage</h3>
|
<h3><i class="fas fa-shield-halved"></i> Compliance & Usage</h3>
|
||||||
<div class="hipaa-info">
|
<div class="hipaa-info">
|
||||||
|
<p><strong>AWS Bedrock</strong> is available with a Business Associate Agreement (BAA) for HIPAA-eligible workloads.</p>
|
||||||
|
<ul>
|
||||||
|
<li>✅ All connections use HTTPS/TLS encryption</li>
|
||||||
|
<li>✅ Authentication with optional 2FA</li>
|
||||||
|
<li>✅ No patient data stored on server beyond session</li>
|
||||||
|
<li>✅ AWS Bedrock supports BAA for HIPAA compliance</li>
|
||||||
|
<li>✅ Azure OpenAI supports BAA for HIPAA compliance</li>
|
||||||
|
</ul>
|
||||||
<p><strong>Important:</strong> Check with your institution's guidelines and policies before use. This tool is not intended for production clinical use without proper organizational authorization and provider BAAs in place. Use with caution.</p>
|
<p><strong>Important:</strong> Check with your institution's guidelines and policies before use. This tool is not intended for production clinical use without proper organizational authorization and provider BAAs in place. Use with caution.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,6 @@
|
||||||
<button class="wv-subtab-btn" data-subtab="milestones">
|
<button class="wv-subtab-btn" data-subtab="milestones">
|
||||||
<i class="fas fa-baby"></i> Milestones
|
<i class="fas fa-baby"></i> Milestones
|
||||||
</button>
|
</button>
|
||||||
<button class="wv-subtab-btn" data-subtab="lincoln">
|
|
||||||
<i class="fas fa-clipboard-list"></i> Lincoln
|
|
||||||
</button>
|
|
||||||
<button class="wv-subtab-btn" data-subtab="shadess" style="display:none;">
|
<button class="wv-subtab-btn" data-subtab="shadess" style="display:none;">
|
||||||
<i class="fas fa-brain"></i> SSHADESS (12+)
|
<i class="fas fa-brain"></i> SSHADESS (12+)
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -122,65 +119,6 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lincoln quick-reference sub-panel -->
|
|
||||||
<div id="wv-panel-lincoln" class="wv-subpanel hidden">
|
|
||||||
<div class="card" style="margin-bottom:10px;">
|
|
||||||
<div class="card-header output-header">
|
|
||||||
<h3><i class="fas fa-clipboard-list"></i> Lincoln Well-Child Quick Reference</h3>
|
|
||||||
<div class="output-actions">
|
|
||||||
<button class="btn-sm btn-primary" data-action="copy" data-target="wv-lincoln-reference"><i class="fas fa-copy"></i> Copy</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="wv-lincoln-reference" class="wv-lincoln-reference">
|
|
||||||
<div class="wv-lincoln-grid">
|
|
||||||
<section class="wv-lincoln-card">
|
|
||||||
<h4>Infancy</h4>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Newborn:</strong> POC visit; check for jaundice.</li>
|
|
||||||
<li><strong>2 weeks:</strong> weight gain, newborn screen, umbilicus check.</li>
|
|
||||||
<li><strong>1 month:</strong> maternal PHQ-9.</li>
|
|
||||||
<li><strong>2 months:</strong> Vaxelis, rotavirus, Prevnar.</li>
|
|
||||||
<li><strong>4 months:</strong> Vaxelis, rotavirus, Prevnar.</li>
|
|
||||||
<li><strong>6 months:</strong> routine vaccines and Prevnar; confirm rotavirus eligibility by product and age.</li>
|
|
||||||
<li><strong>9 months:</strong> SWYC; no routine vaccines noted.</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="wv-lincoln-card">
|
|
||||||
<h4>Toddler / Preschool</h4>
|
|
||||||
<ul>
|
|
||||||
<li><strong>12 months:</strong> MMR, varicella, Hep A; CBC and lead.</li>
|
|
||||||
<li><strong>15 months:</strong> Pentacel, Prevnar, influenza.</li>
|
|
||||||
<li><strong>18 months:</strong> POSI, SWYC; Hep A second dose.</li>
|
|
||||||
<li><strong>2 years:</strong> POSI/SWYC; CBC and lead.</li>
|
|
||||||
<li><strong>3 years:</strong> blood pressure check and vision screening; BP is commonly missed and can be added on diagnosis.</li>
|
|
||||||
<li><strong>4 years:</strong> hearing and vision start; ProQuad and Kinrix.</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="wv-lincoln-card">
|
|
||||||
<h4>School Age / Adolescence</h4>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Lipid screening:</strong> AAP screening at 9-11 years and 17-21 years.</li>
|
|
||||||
<li><strong>Depression screening:</strong> begin at 12 years and older.</li>
|
|
||||||
<li><strong>MenB:</strong> discuss Bexsero/MenB at 16-23 years, preferably 16-18 years, when chosen or indicated.</li>
|
|
||||||
<li><strong>Age ≥18 years:</strong> Hep C testing.</li>
|
|
||||||
<li><strong>Cervical cancer screening:</strong> start Pap smear screening at 21 years.</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="wv-lincoln-card">
|
|
||||||
<h4>Catch-Up / Screening Reminders</h4>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Influenza:</strong> if a child 6 months through 8 years needs 2 doses, give doses 4 weeks apart.</li>
|
|
||||||
<li><strong>Lead:</strong> continue lead screening reminders through age 6 years; add diagnosis when needed.</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SSHADESS sub-panel (age 12+) -->
|
<!-- SSHADESS sub-panel (age 12+) -->
|
||||||
<div id="wv-panel-shadess" class="wv-subpanel hidden">
|
<div id="wv-panel-shadess" class="wv-subpanel hidden">
|
||||||
<div class="card" style="margin-bottom:10px;">
|
<div class="card" style="margin-bottom:10px;">
|
||||||
|
|
@ -367,3 +305,4 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,6 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
|
||||||
.btn-generate-green{background:var(--green);box-shadow:0 3px 10px rgba(16,185,129,0.25);}
|
.btn-generate-green{background:var(--green);box-shadow:0 3px 10px rgba(16,185,129,0.25);}
|
||||||
|
|
||||||
.btn-sm{display:inline-flex;align-items:center;gap:4px;padding:5px 10px;border:none;border-radius:6px;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;transition:all 0.15s;}
|
.btn-sm{display:inline-flex;align-items:center;gap:4px;padding:5px 10px;border:none;border-radius:6px;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;transition:all 0.15s;}
|
||||||
#btn-assistant-cancel[hidden]{display:none!important;}
|
|
||||||
.btn-lg{display:inline-flex;align-items:center;gap:6px;padding:9px 18px;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s;}
|
.btn-lg{display:inline-flex;align-items:center;gap:6px;padding:9px 18px;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s;}
|
||||||
.btn-primary{background:var(--blue);color:white;}.btn-primary:hover{background:var(--blue-dark);}
|
.btn-primary{background:var(--blue);color:white;}.btn-primary:hover{background:var(--blue-dark);}
|
||||||
.btn-ghost{background:var(--g200);color:var(--g700);}.btn-ghost:hover{background:var(--g300);}
|
.btn-ghost{background:var(--g200);color:var(--g700);}.btn-ghost:hover{background:var(--g300);}
|
||||||
|
|
@ -397,21 +396,6 @@ textarea.full-input{resize:vertical;}
|
||||||
.wv-section-title{font-size:14px;font-weight:700;color:var(--g700);margin-bottom:12px;display:flex;align-items:center;gap:8px;}
|
.wv-section-title{font-size:14px;font-weight:700;color:var(--g700);margin-bottom:12px;display:flex;align-items:center;gap:8px;}
|
||||||
.wv-section-title i{color:var(--blue);}
|
.wv-section-title i{color:var(--blue);}
|
||||||
|
|
||||||
/* Lincoln quick reference */
|
|
||||||
.wv-lincoln-reference{padding:14px 16px;background:var(--g50);}
|
|
||||||
.wv-lincoln-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px;}
|
|
||||||
.wv-lincoln-card{border:1px solid var(--g100);border-radius:12px;padding:14px 16px;background:white;box-shadow:0 1px 2px rgba(15,23,42,0.04);}
|
|
||||||
.wv-lincoln-card h4{margin:0 0 10px;font-size:14px;color:var(--g800);}
|
|
||||||
.wv-lincoln-card ul{margin:0;padding-left:18px;color:var(--g700);font-size:13px;line-height:1.65;}
|
|
||||||
.wv-lincoln-card li{margin-bottom:6px;}
|
|
||||||
.wv-lincoln-card li:last-child{margin-bottom:0;}
|
|
||||||
@media(max-width:640px){
|
|
||||||
.wv-lincoln-reference{padding:10px;}
|
|
||||||
.wv-lincoln-grid{grid-template-columns:1fr;gap:10px;}
|
|
||||||
.wv-lincoln-card{padding:12px;}
|
|
||||||
.wv-lincoln-card ul{font-size:12.5px;line-height:1.55;}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Billing */
|
/* Billing */
|
||||||
.wv-billing-grid{display:flex;flex-wrap:wrap;gap:12px;align-items:center;}
|
.wv-billing-grid{display:flex;flex-wrap:wrap;gap:12px;align-items:center;}
|
||||||
.wv-billing-cell{display:flex;align-items:center;gap:8px;}
|
.wv-billing-cell{display:flex;align-items:center;gap:8px;}
|
||||||
|
|
@ -642,7 +626,7 @@ textarea.full-input{resize:vertical;}
|
||||||
.lh-quiz-q-type{font-size:11px;color:var(--g400);background:var(--g100);padding:2px 8px;border-radius:4px;}
|
.lh-quiz-q-type{font-size:11px;color:var(--g400);background:var(--g100);padding:2px 8px;border-radius:4px;}
|
||||||
.lh-quiz-q-text{font-size:16px;font-weight:600;margin-bottom:14px;color:var(--g800);line-height:1.5;}
|
.lh-quiz-q-text{font-size:16px;font-weight:600;margin-bottom:14px;color:var(--g800);line-height:1.5;}
|
||||||
.lh-quiz-options{display:flex;flex-direction:column;gap:8px;}
|
.lh-quiz-options{display:flex;flex-direction:column;gap:8px;}
|
||||||
.lh-quiz-option{display:flex;align-items:center;gap:12px;padding:14px 18px;border:2px solid var(--g200);border-radius:10px;cursor:pointer;transition:all 0.2s;font-size:14px;line-height:1.4;background:white;user-select:none;}
|
.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:hover{border-color:var(--blue);background:var(--blue-light);transform:translateY(-1px);box-shadow:0 2px 8px rgba(37,99,235,0.1);}
|
.lh-quiz-option:hover{border-color:var(--blue);background:var(--blue-light);transform:translateY(-1px);box-shadow:0 2px 8px rgba(37,99,235,0.1);}
|
||||||
.lh-quiz-option input[type="radio"]{accent-color:var(--blue);width:18px;height:18px;flex-shrink:0;}
|
.lh-quiz-option input[type="radio"]{accent-color:var(--blue);width:18px;height:18px;flex-shrink:0;}
|
||||||
.lh-quiz-option span{flex:1;}
|
.lh-quiz-option span{flex:1;}
|
||||||
|
|
@ -1155,8 +1139,6 @@ textarea.full-input{resize:vertical;}
|
||||||
.docs-reader-body th{background:var(--g50);font-weight:600;}
|
.docs-reader-body th{background:var(--g50);font-weight:600;}
|
||||||
.docs-reader-body a{color:var(--blue);text-decoration:none;}
|
.docs-reader-body a{color:var(--blue);text-decoration:none;}
|
||||||
.docs-reader-body a:hover{text-decoration:underline;}
|
.docs-reader-body a:hover{text-decoration:underline;}
|
||||||
.docs-reader-body .docs-anchor-link{color:var(--blue);cursor:pointer;text-decoration:none;}
|
|
||||||
.docs-reader-body .docs-anchor-link:hover{text-decoration:underline;}
|
|
||||||
.docs-reader-body hr{border:0;border-top:1px solid var(--g200);margin:1.6em 0;}
|
.docs-reader-body hr{border:0;border-top:1px solid var(--g200);margin:1.6em 0;}
|
||||||
|
|
||||||
@media (max-width:900px){
|
@media (max-width:900px){
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"version": "1.0",
|
|
||||||
"source": "Bhutani VK et al., Pediatrics 1999;103(1):6-14; cross-checked against AAP 2004 CPG reproduction",
|
|
||||||
"zones": {
|
|
||||||
"p95": { "6": 6.0, "12": 7.2, "18": 8.5, "24": 9.6, "30": 11.2, "36": 12.8, "42": 13.8, "48": 14.8, "54": 15.6, "60": 16.2, "66": 16.8, "72": 17.4, "84": 18.0, "96": 18.4, "108": 18.8, "120": 19.0 },
|
|
||||||
"p75": { "6": 4.5, "12": 5.5, "18": 6.6, "24": 7.8, "30": 9.2, "36": 10.6, "42": 11.6, "48": 12.6, "54": 13.4, "60": 14.0, "66": 14.6, "72": 15.0, "84": 15.4, "96": 15.6, "108": 15.8, "120": 16.0 },
|
|
||||||
"p40": { "6": 3.0, "12": 4.0, "18": 5.0, "24": 6.2, "30": 7.2, "36": 8.4, "42": 9.2, "48": 10.0, "54": 10.6, "60": 11.2, "66": 11.8, "72": 12.2, "84": 12.6, "96": 12.8, "108": 13.0, "120": 13.2 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,380 +0,0 @@
|
||||||
{
|
|
||||||
"version": "1.0",
|
|
||||||
"source": "CDC 2000 BMI-for-age LMS values, 24-240 months every 6 months as previously embedded in calculators.js",
|
|
||||||
"lms": {
|
|
||||||
"male": {
|
|
||||||
"24": {
|
|
||||||
"L": -1.982374,
|
|
||||||
"M": 16.5478,
|
|
||||||
"S": 0.080127
|
|
||||||
},
|
|
||||||
"30": {
|
|
||||||
"L": -1.642107,
|
|
||||||
"M": 16.2497,
|
|
||||||
"S": 0.075499
|
|
||||||
},
|
|
||||||
"36": {
|
|
||||||
"L": -1.419991,
|
|
||||||
"M": 16.0003,
|
|
||||||
"S": 0.072634
|
|
||||||
},
|
|
||||||
"42": {
|
|
||||||
"L": -1.438165,
|
|
||||||
"M": 15.7941,
|
|
||||||
"S": 0.071495
|
|
||||||
},
|
|
||||||
"48": {
|
|
||||||
"L": -1.714869,
|
|
||||||
"M": 15.6282,
|
|
||||||
"S": 0.071889
|
|
||||||
},
|
|
||||||
"54": {
|
|
||||||
"L": -2.155348,
|
|
||||||
"M": 15.5026,
|
|
||||||
"S": 0.073491
|
|
||||||
},
|
|
||||||
"60": {
|
|
||||||
"L": -2.615166,
|
|
||||||
"M": 15.4191,
|
|
||||||
"S": 0.075992
|
|
||||||
},
|
|
||||||
"66": {
|
|
||||||
"L": -2.981797,
|
|
||||||
"M": 15.3795,
|
|
||||||
"S": 0.079211
|
|
||||||
},
|
|
||||||
"72": {
|
|
||||||
"L": -3.211705,
|
|
||||||
"M": 15.3835,
|
|
||||||
"S": 0.083048
|
|
||||||
},
|
|
||||||
"78": {
|
|
||||||
"L": -3.314769,
|
|
||||||
"M": 15.429,
|
|
||||||
"S": 0.0874
|
|
||||||
},
|
|
||||||
"84": {
|
|
||||||
"L": -3.323189,
|
|
||||||
"M": 15.5129,
|
|
||||||
"S": 0.092131
|
|
||||||
},
|
|
||||||
"90": {
|
|
||||||
"L": -3.270455,
|
|
||||||
"M": 15.6317,
|
|
||||||
"S": 0.097082
|
|
||||||
},
|
|
||||||
"96": {
|
|
||||||
"L": -3.183058,
|
|
||||||
"M": 15.7823,
|
|
||||||
"S": 0.102091
|
|
||||||
},
|
|
||||||
"102": {
|
|
||||||
"L": -3.079383,
|
|
||||||
"M": 15.9617,
|
|
||||||
"S": 0.107013
|
|
||||||
},
|
|
||||||
"108": {
|
|
||||||
"L": -2.971148,
|
|
||||||
"M": 16.1671,
|
|
||||||
"S": 0.111721
|
|
||||||
},
|
|
||||||
"114": {
|
|
||||||
"L": -2.865311,
|
|
||||||
"M": 16.3961,
|
|
||||||
"S": 0.116113
|
|
||||||
},
|
|
||||||
"120": {
|
|
||||||
"L": -2.765648,
|
|
||||||
"M": 16.6461,
|
|
||||||
"S": 0.120112
|
|
||||||
},
|
|
||||||
"126": {
|
|
||||||
"L": -2.673903,
|
|
||||||
"M": 16.9151,
|
|
||||||
"S": 0.123664
|
|
||||||
},
|
|
||||||
"132": {
|
|
||||||
"L": -2.59056,
|
|
||||||
"M": 17.2009,
|
|
||||||
"S": 0.126735
|
|
||||||
},
|
|
||||||
"138": {
|
|
||||||
"L": -2.51532,
|
|
||||||
"M": 17.5014,
|
|
||||||
"S": 0.129309
|
|
||||||
},
|
|
||||||
"144": {
|
|
||||||
"L": -2.447426,
|
|
||||||
"M": 17.8146,
|
|
||||||
"S": 0.131389
|
|
||||||
},
|
|
||||||
"150": {
|
|
||||||
"L": -2.385858,
|
|
||||||
"M": 18.1387,
|
|
||||||
"S": 0.132991
|
|
||||||
},
|
|
||||||
"156": {
|
|
||||||
"L": -2.329457,
|
|
||||||
"M": 18.4718,
|
|
||||||
"S": 0.134141
|
|
||||||
},
|
|
||||||
"162": {
|
|
||||||
"L": -2.277017,
|
|
||||||
"M": 18.812,
|
|
||||||
"S": 0.13488
|
|
||||||
},
|
|
||||||
"168": {
|
|
||||||
"L": -2.227362,
|
|
||||||
"M": 19.1576,
|
|
||||||
"S": 0.135251
|
|
||||||
},
|
|
||||||
"174": {
|
|
||||||
"L": -2.179426,
|
|
||||||
"M": 19.5067,
|
|
||||||
"S": 0.135309
|
|
||||||
},
|
|
||||||
"180": {
|
|
||||||
"L": -2.132345,
|
|
||||||
"M": 19.8577,
|
|
||||||
"S": 0.13511
|
|
||||||
},
|
|
||||||
"186": {
|
|
||||||
"L": -2.085574,
|
|
||||||
"M": 20.2086,
|
|
||||||
"S": 0.134718
|
|
||||||
},
|
|
||||||
"192": {
|
|
||||||
"L": -2.039015,
|
|
||||||
"M": 20.5576,
|
|
||||||
"S": 0.134198
|
|
||||||
},
|
|
||||||
"198": {
|
|
||||||
"L": -1.99315,
|
|
||||||
"M": 20.9029,
|
|
||||||
"S": 0.13362
|
|
||||||
},
|
|
||||||
"204": {
|
|
||||||
"L": -1.949135,
|
|
||||||
"M": 21.2425,
|
|
||||||
"S": 0.133057
|
|
||||||
},
|
|
||||||
"210": {
|
|
||||||
"L": -1.908831,
|
|
||||||
"M": 21.5742,
|
|
||||||
"S": 0.132585
|
|
||||||
},
|
|
||||||
"216": {
|
|
||||||
"L": -1.87467,
|
|
||||||
"M": 21.8959,
|
|
||||||
"S": 0.132286
|
|
||||||
},
|
|
||||||
"222": {
|
|
||||||
"L": -1.849323,
|
|
||||||
"M": 22.2054,
|
|
||||||
"S": 0.132249
|
|
||||||
},
|
|
||||||
"228": {
|
|
||||||
"L": -1.835138,
|
|
||||||
"M": 22.5007,
|
|
||||||
"S": 0.132566
|
|
||||||
},
|
|
||||||
"234": {
|
|
||||||
"L": -1.833401,
|
|
||||||
"M": 22.7799,
|
|
||||||
"S": 0.133339
|
|
||||||
},
|
|
||||||
"240": {
|
|
||||||
"L": -1.843581,
|
|
||||||
"M": 23.0414,
|
|
||||||
"S": 0.134675
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"female": {
|
|
||||||
"24": {
|
|
||||||
"L": -1.024497,
|
|
||||||
"M": 16.388,
|
|
||||||
"S": 0.085026
|
|
||||||
},
|
|
||||||
"30": {
|
|
||||||
"L": -1.534542,
|
|
||||||
"M": 16.0059,
|
|
||||||
"S": 0.080932
|
|
||||||
},
|
|
||||||
"36": {
|
|
||||||
"L": -2.096829,
|
|
||||||
"M": 15.6992,
|
|
||||||
"S": 0.078605
|
|
||||||
},
|
|
||||||
"42": {
|
|
||||||
"L": -2.618733,
|
|
||||||
"M": 15.4647,
|
|
||||||
"S": 0.077904
|
|
||||||
},
|
|
||||||
"48": {
|
|
||||||
"L": -3.018522,
|
|
||||||
"M": 15.2985,
|
|
||||||
"S": 0.078713
|
|
||||||
},
|
|
||||||
"54": {
|
|
||||||
"L": -3.2593,
|
|
||||||
"M": 15.1961,
|
|
||||||
"S": 0.080904
|
|
||||||
},
|
|
||||||
"60": {
|
|
||||||
"L": -3.350078,
|
|
||||||
"M": 15.1519,
|
|
||||||
"S": 0.0843
|
|
||||||
},
|
|
||||||
"66": {
|
|
||||||
"L": -3.325522,
|
|
||||||
"M": 15.1606,
|
|
||||||
"S": 0.08868
|
|
||||||
},
|
|
||||||
"72": {
|
|
||||||
"L": -3.225607,
|
|
||||||
"M": 15.2169,
|
|
||||||
"S": 0.093803
|
|
||||||
},
|
|
||||||
"78": {
|
|
||||||
"L": -3.084291,
|
|
||||||
"M": 15.3161,
|
|
||||||
"S": 0.099427
|
|
||||||
},
|
|
||||||
"84": {
|
|
||||||
"L": -2.926187,
|
|
||||||
"M": 15.4536,
|
|
||||||
"S": 0.105325
|
|
||||||
},
|
|
||||||
"90": {
|
|
||||||
"L": -2.76731,
|
|
||||||
"M": 15.6252,
|
|
||||||
"S": 0.111295
|
|
||||||
},
|
|
||||||
"96": {
|
|
||||||
"L": -2.617192,
|
|
||||||
"M": 15.827,
|
|
||||||
"S": 0.117159
|
|
||||||
},
|
|
||||||
"102": {
|
|
||||||
"L": -2.480952,
|
|
||||||
"M": 16.0552,
|
|
||||||
"S": 0.122771
|
|
||||||
},
|
|
||||||
"108": {
|
|
||||||
"L": -2.360921,
|
|
||||||
"M": 16.3061,
|
|
||||||
"S": 0.128014
|
|
||||||
},
|
|
||||||
"114": {
|
|
||||||
"L": -2.257782,
|
|
||||||
"M": 16.5763,
|
|
||||||
"S": 0.132797
|
|
||||||
},
|
|
||||||
"120": {
|
|
||||||
"L": -2.171296,
|
|
||||||
"M": 16.8623,
|
|
||||||
"S": 0.137057
|
|
||||||
},
|
|
||||||
"126": {
|
|
||||||
"L": -2.100749,
|
|
||||||
"M": 17.161,
|
|
||||||
"S": 0.140754
|
|
||||||
},
|
|
||||||
"132": {
|
|
||||||
"L": -2.045235,
|
|
||||||
"M": 17.4691,
|
|
||||||
"S": 0.143868
|
|
||||||
},
|
|
||||||
"138": {
|
|
||||||
"L": -2.003802,
|
|
||||||
"M": 17.7836,
|
|
||||||
"S": 0.146399
|
|
||||||
},
|
|
||||||
"144": {
|
|
||||||
"L": -1.975521,
|
|
||||||
"M": 18.1015,
|
|
||||||
"S": 0.148361
|
|
||||||
},
|
|
||||||
"150": {
|
|
||||||
"L": -1.95952,
|
|
||||||
"M": 18.42,
|
|
||||||
"S": 0.149783
|
|
||||||
},
|
|
||||||
"156": {
|
|
||||||
"L": -1.954978,
|
|
||||||
"M": 18.7364,
|
|
||||||
"S": 0.150705
|
|
||||||
},
|
|
||||||
"162": {
|
|
||||||
"L": -1.9611,
|
|
||||||
"M": 19.0481,
|
|
||||||
"S": 0.151176
|
|
||||||
},
|
|
||||||
"168": {
|
|
||||||
"L": -1.977074,
|
|
||||||
"M": 19.3526,
|
|
||||||
"S": 0.151256
|
|
||||||
},
|
|
||||||
"174": {
|
|
||||||
"L": -2.002014,
|
|
||||||
"M": 19.6475,
|
|
||||||
"S": 0.15101
|
|
||||||
},
|
|
||||||
"180": {
|
|
||||||
"L": -2.034893,
|
|
||||||
"M": 19.9306,
|
|
||||||
"S": 0.150512
|
|
||||||
},
|
|
||||||
"186": {
|
|
||||||
"L": -2.07446,
|
|
||||||
"M": 20.1998,
|
|
||||||
"S": 0.149843
|
|
||||||
},
|
|
||||||
"192": {
|
|
||||||
"L": -2.119157,
|
|
||||||
"M": 20.4533,
|
|
||||||
"S": 0.14909
|
|
||||||
},
|
|
||||||
"198": {
|
|
||||||
"L": -2.167045,
|
|
||||||
"M": 20.6891,
|
|
||||||
"S": 0.148349
|
|
||||||
},
|
|
||||||
"204": {
|
|
||||||
"L": -2.215738,
|
|
||||||
"M": 20.9058,
|
|
||||||
"S": 0.147723
|
|
||||||
},
|
|
||||||
"210": {
|
|
||||||
"L": -2.262382,
|
|
||||||
"M": 21.1016,
|
|
||||||
"S": 0.147323
|
|
||||||
},
|
|
||||||
"216": {
|
|
||||||
"L": -2.303688,
|
|
||||||
"M": 21.2753,
|
|
||||||
"S": 0.147269
|
|
||||||
},
|
|
||||||
"222": {
|
|
||||||
"L": -2.336038,
|
|
||||||
"M": 21.4255,
|
|
||||||
"S": 0.147689
|
|
||||||
},
|
|
||||||
"228": {
|
|
||||||
"L": -2.355678,
|
|
||||||
"M": 21.5508,
|
|
||||||
"S": 0.148724
|
|
||||||
},
|
|
||||||
"234": {
|
|
||||||
"L": -2.35898,
|
|
||||||
"M": 21.6501,
|
|
||||||
"S": 0.150521
|
|
||||||
},
|
|
||||||
"240": {
|
|
||||||
"L": -2.342797,
|
|
||||||
"M": 21.7219,
|
|
||||||
"S": 0.153241
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"version": "1.0",
|
|
||||||
"source": "Harriet Lane Handbook",
|
|
||||||
"ageGroups": {
|
|
||||||
"premie": { "label": "Premie (1-3 kg)", "bvm": "Infant", "nasal": "12 Fr", "oral": "Infant", "blade": "Miller 0", "ett": "2.5-3.0", "lma": "1", "glidescope": "1", "iv": "22-24 ga", "cvl": "3 Fr", "ngt": "5 Fr", "chest": "10-12 Fr", "foley": "6 Fr" },
|
|
||||||
"newborn": { "label": "Newborn (2-4 kg)", "bvm": "Infant", "nasal": "14-16 Fr", "oral": "Small 50 mm", "blade": "Miller 0", "ett": "3.0-3.5", "lma": "1", "glidescope": "1", "iv": "22-24 ga", "cvl": "3-4 Fr", "ngt": "5-8 Fr", "chest": "10-12 Fr", "foley": "6 Fr" },
|
|
||||||
"6mo": { "label": "6 months (6-8 kg)", "bvm": "Infant", "nasal": "14-16 Fr", "oral": "Small 60 mm", "blade": "Miller 1", "ett": "3.5", "lma": "1.5", "glidescope": "2", "iv": "20-24 ga", "cvl": "4 Fr", "ngt": "8 Fr", "chest": "12-18 Fr", "foley": "8 Fr" },
|
|
||||||
"1yr": { "label": "1 year (10 kg)", "bvm": "Small child", "nasal": "14-18 Fr", "oral": "Small 60 mm", "blade": "Miller 1 / MAC 2", "ett": "4.0", "lma": "2", "glidescope": "2", "iv": "20-24 ga", "cvl": "4-5 Fr", "ngt": "10 Fr", "chest": "16-20 Fr", "foley": "8 Fr" },
|
|
||||||
"2-3yr": { "label": "2-3 years (12-16 kg)", "bvm": "Small child", "nasal": "14-18 Fr", "oral": "Small 70 mm", "blade": "Miller 1 / MAC 2", "ett": "4.0-4.5", "lma": "2", "glidescope": "2", "iv": "18-22 ga", "cvl": "4-5 Fr", "ngt": "10-12 Fr", "chest": "16-24 Fr", "foley": "8 Fr" },
|
|
||||||
"4-6yr": { "label": "4-6 years (20-25 kg)", "bvm": "Child", "nasal": "16-20 Fr", "oral": "Small 70-80 mm", "blade": "Miller 2 / MAC 2", "ett": "4.5-5.0", "lma": "2.5", "glidescope": "3", "iv": "18-22 ga", "cvl": "5 Fr", "ngt": "12-14 Fr", "chest": "20-28 Fr", "foley": "8 Fr" },
|
|
||||||
"7-10yr": { "label": "7-10 years (25-35 kg)", "bvm": "Child / Small adult", "nasal": "18-22 Fr", "oral": "Medium 80-90 mm", "blade": "Miller 2 / MAC 2", "ett": "5.5-6.0", "lma": "2.5-3", "glidescope": "3", "iv": "18-22 ga", "cvl": "5 Fr", "ngt": "12-14 Fr", "chest": "20-32 Fr", "foley": "8 Fr" },
|
|
||||||
"11-15yr": { "label": "11-15 years (40-50 kg)", "bvm": "Adult", "nasal": "22-36 Fr", "oral": "Medium 90 mm", "blade": "Miller 2 / MAC 3", "ett": "6.0-6.5", "lma": "3", "glidescope": "3 or 4", "iv": "18-20 ga", "cvl": "7 Fr", "ngt": "14-18 Fr", "chest": "28-38 Fr", "foley": "10 Fr" },
|
|
||||||
"16yr": { "label": "16+ years (>50 kg)", "bvm": "Adult", "nasal": "22-36 Fr", "oral": "Medium 90 mm", "blade": "Miller 2 / MAC 3", "ett": "7.0-8.0", "lma": "4", "glidescope": "3 or 4", "iv": "18-20 ga", "cvl": "7 Fr", "ngt": "14-18 Fr", "chest": "28-42 Fr", "foley": "12 Fr" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
{
|
|
||||||
"version": "1.0",
|
|
||||||
"source": "Fenton TR, Kim JH. BMC Pediatrics 2013;13:59",
|
|
||||||
"weightLms": {
|
|
||||||
"male": {
|
|
||||||
"22": { "L": 0.21, "M": 496, "S": 0.17 },
|
|
||||||
"23": { "L": 0.21, "M": 575, "S": 0.17 },
|
|
||||||
"24": { "L": 0.21, "M": 660, "S": 0.17 },
|
|
||||||
"25": { "L": 0.21, "M": 762, "S": 0.16 },
|
|
||||||
"26": { "L": 0.21, "M": 870, "S": 0.16 },
|
|
||||||
"27": { "L": 0.20, "M": 993, "S": 0.15 },
|
|
||||||
"28": { "L": 0.20, "M": 1124, "S": 0.15 },
|
|
||||||
"29": { "L": 0.19, "M": 1272, "S": 0.14 },
|
|
||||||
"30": { "L": 0.18, "M": 1430, "S": 0.14 },
|
|
||||||
"31": { "L": 0.17, "M": 1607, "S": 0.14 },
|
|
||||||
"32": { "L": 0.15, "M": 1795, "S": 0.14 },
|
|
||||||
"33": { "L": 0.13, "M": 2008, "S": 0.13 },
|
|
||||||
"34": { "L": 0.12, "M": 2230, "S": 0.13 },
|
|
||||||
"35": { "L": 0.10, "M": 2467, "S": 0.13 },
|
|
||||||
"36": { "L": 0.08, "M": 2710, "S": 0.13 },
|
|
||||||
"37": { "L": 0.06, "M": 2948, "S": 0.12 },
|
|
||||||
"38": { "L": 0.04, "M": 3195, "S": 0.12 },
|
|
||||||
"39": { "L": 0.02, "M": 3380, "S": 0.12 },
|
|
||||||
"40": { "L": 0.01, "M": 3530, "S": 0.12 },
|
|
||||||
"41": { "L": 0.00, "M": 3660, "S": 0.12 },
|
|
||||||
"42": { "L": -0.02, "M": 3820, "S": 0.12 }
|
|
||||||
},
|
|
||||||
"female": {
|
|
||||||
"22": { "L": 0.23, "M": 474, "S": 0.17 },
|
|
||||||
"23": { "L": 0.22, "M": 538, "S": 0.17 },
|
|
||||||
"24": { "L": 0.22, "M": 610, "S": 0.17 },
|
|
||||||
"25": { "L": 0.22, "M": 705, "S": 0.16 },
|
|
||||||
"26": { "L": 0.22, "M": 810, "S": 0.16 },
|
|
||||||
"27": { "L": 0.21, "M": 920, "S": 0.15 },
|
|
||||||
"28": { "L": 0.21, "M": 1040, "S": 0.15 },
|
|
||||||
"29": { "L": 0.20, "M": 1178, "S": 0.14 },
|
|
||||||
"30": { "L": 0.19, "M": 1330, "S": 0.14 },
|
|
||||||
"31": { "L": 0.18, "M": 1500, "S": 0.14 },
|
|
||||||
"32": { "L": 0.16, "M": 1680, "S": 0.14 },
|
|
||||||
"33": { "L": 0.14, "M": 1880, "S": 0.13 },
|
|
||||||
"34": { "L": 0.12, "M": 2090, "S": 0.13 },
|
|
||||||
"35": { "L": 0.10, "M": 2310, "S": 0.13 },
|
|
||||||
"36": { "L": 0.08, "M": 2540, "S": 0.13 },
|
|
||||||
"37": { "L": 0.06, "M": 2766, "S": 0.12 },
|
|
||||||
"38": { "L": 0.04, "M": 3000, "S": 0.12 },
|
|
||||||
"39": { "L": 0.02, "M": 3180, "S": 0.12 },
|
|
||||||
"40": { "L": 0.01, "M": 3340, "S": 0.12 },
|
|
||||||
"41": { "L": 0.00, "M": 3480, "S": 0.12 },
|
|
||||||
"42": { "L": -0.02, "M": 3630, "S": 0.12 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,24 +0,0 @@
|
||||||
{
|
|
||||||
"version": "1.0",
|
|
||||||
"source": "Harriet Lane Handbook / AHA PALS 2020",
|
|
||||||
"categories": {
|
|
||||||
"cardiac": { "label": "Cardiac", "color": "#ef4444" },
|
|
||||||
"metabolic": { "label": "Metabolic", "color": "#3b82f6" },
|
|
||||||
"reversal": { "label": "Reversal", "color": "#10b981" }
|
|
||||||
},
|
|
||||||
"medications": [
|
|
||||||
{ "name": "Adenosine", "indication": "SVT", "category": "cardiac", "route": "IV/IO rapid bolus", "formula": "adenosine" },
|
|
||||||
{ "name": "Amiodarone", "indication": "VT / VF", "category": "cardiac", "route": "IV/IO", "formula": "amiodarone" },
|
|
||||||
{ "name": "Atropine", "indication": "Bradycardia", "category": "cardiac", "route": "IV/IO/IM", "formula": "atropine" },
|
|
||||||
{ "name": "Calcium Chloride 10%", "indication": "Hypocalcemia / Hyperkalemia", "category": "metabolic", "route": "IV/IO", "formula": "calciumChloride" },
|
|
||||||
{ "name": "Calcium Gluconate 10%", "indication": "Hypocalcemia / Hyperkalemia", "category": "metabolic", "route": "IV/IO", "formula": "calciumGluconate" },
|
|
||||||
{ "name": "Dextrose", "indication": "Hypoglycemia", "category": "metabolic", "route": "IV", "formula": "dextrose" },
|
|
||||||
{ "name": "Epinephrine", "indication": "Pulseless arrest / Anaphylaxis", "category": "cardiac", "route": "IV/IO/IM/ETT", "formula": "epinephrine" },
|
|
||||||
{ "name": "Hydrocortisone", "indication": "Adrenal crisis", "category": "metabolic", "route": "IV/IM/IO", "formula": "hydrocortisone" },
|
|
||||||
{ "name": "Insulin (Regular)", "indication": "Hyperkalemia", "category": "metabolic", "route": "IV", "formula": "insulin" },
|
|
||||||
{ "name": "Lidocaine", "indication": "Antiarrhythmic", "category": "cardiac", "route": "IV/IO", "formula": "lidocaine" },
|
|
||||||
{ "name": "Magnesium Sulfate", "indication": "Torsades de Pointes", "category": "cardiac", "route": "IV/IO", "formula": "magnesiumSulfate" },
|
|
||||||
{ "name": "Naloxone", "indication": "Opioid overdose", "category": "reversal", "route": "IV/IO/IM/IN/ETT", "formula": "naloxone" },
|
|
||||||
{ "name": "Sodium Bicarbonate", "indication": "Metabolic acidosis", "category": "metabolic", "route": "IV/IO", "formula": "sodiumBicarbonate" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
{
|
|
||||||
"version": "1.0",
|
|
||||||
"source": "Harriet Lane Handbook 23rd Edition reference values",
|
|
||||||
"ageGroups": {
|
|
||||||
"premie": {
|
|
||||||
"label": "Premie",
|
|
||||||
"hr": { "awake": "120-170", "sleeping": "100-150" },
|
|
||||||
"rr": "40-70",
|
|
||||||
"sbp": "55-75",
|
|
||||||
"dbp": "35-45",
|
|
||||||
"temp": "36.5-37.5",
|
|
||||||
"weight": "0.5-2.5 kg",
|
|
||||||
"spo2": "88-95% (target)",
|
|
||||||
"notes": [
|
|
||||||
"HR and RR are highly variable and depend on gestational age",
|
|
||||||
"BP increases with gestational age and postnatal age",
|
|
||||||
"Target SpO2 88-95% to reduce retinopathy of prematurity risk",
|
|
||||||
"Temperature instability is common - use servo-controlled warmers",
|
|
||||||
"Bradycardia (<100 bpm) and apnea are common in premature infants"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"0-3mo": {
|
|
||||||
"label": "0-3 Months",
|
|
||||||
"hr": { "awake": "100-150", "sleeping": "85-135" },
|
|
||||||
"rr": "35-55",
|
|
||||||
"sbp": "65-85",
|
|
||||||
"dbp": "45-55",
|
|
||||||
"temp": "36.5-37.5",
|
|
||||||
"weight": "2.5-6 kg",
|
|
||||||
"spo2": ">95%",
|
|
||||||
"notes": [
|
|
||||||
"HR normally increases with crying (up to 180-190 bpm) - this is physiologic",
|
|
||||||
"Periodic breathing (pauses <10 sec) is normal in neonates",
|
|
||||||
"Acrocyanosis (blue hands/feet) is normal; central cyanosis is not",
|
|
||||||
"BP is best measured in the right arm (pre-ductal) in neonates",
|
|
||||||
"Normal weight loss of 5-7% in first 3-5 days; regain by 10-14 days"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"3-6mo": {
|
|
||||||
"label": "3-6 Months",
|
|
||||||
"hr": { "awake": "90-120", "sleeping": "75-110" },
|
|
||||||
"rr": "30-45",
|
|
||||||
"sbp": "70-90",
|
|
||||||
"dbp": "50-65",
|
|
||||||
"temp": "36.5-37.5",
|
|
||||||
"weight": "5-8 kg",
|
|
||||||
"spo2": ">95%",
|
|
||||||
"notes": [
|
|
||||||
"Expected weight gain: 20-30 g/day (150-200 g/week)",
|
|
||||||
"HR gradually decreases as vagal tone matures",
|
|
||||||
"RR >60 at rest may indicate lower respiratory tract disease",
|
|
||||||
"BP should be measured with appropriate cuff size (width 40% of arm circumference)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"6-12mo": {
|
|
||||||
"label": "6-12 Months",
|
|
||||||
"hr": { "awake": "80-120", "sleeping": "70-110" },
|
|
||||||
"rr": "25-40",
|
|
||||||
"sbp": "80-100",
|
|
||||||
"dbp": "55-65",
|
|
||||||
"temp": "36.0-37.5",
|
|
||||||
"weight": "8-10 kg",
|
|
||||||
"spo2": ">95%",
|
|
||||||
"notes": [
|
|
||||||
"Expected weight: triple birth weight by 12 months (~10 kg average)",
|
|
||||||
"Weight gain slows to ~10-15 g/day",
|
|
||||||
"Sinus arrhythmia (HR varies with breathing) is normal",
|
|
||||||
"Febrile tachycardia: HR increases ~10 bpm per 1 degree C above 37"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"1-3yr": {
|
|
||||||
"label": "1-3 Years",
|
|
||||||
"hr": { "awake": "70-110", "sleeping": "60-100" },
|
|
||||||
"rr": "20-30",
|
|
||||||
"sbp": "90-105",
|
|
||||||
"dbp": "55-70",
|
|
||||||
"temp": "36.0-37.5",
|
|
||||||
"weight": "10-15 kg",
|
|
||||||
"spo2": ">95%",
|
|
||||||
"notes": [
|
|
||||||
"Expected weight gain: ~200-250 g/month (2-2.5 kg/year)",
|
|
||||||
"Tachycardia: HR >110 at rest warrants evaluation",
|
|
||||||
"Tachypnea: RR >30 at rest may indicate respiratory distress",
|
|
||||||
"BP screening begins at age 3 per AAP 2017 guidelines",
|
|
||||||
"Estimated weight: 2 x (age in years) + 8"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"3-6yr": {
|
|
||||||
"label": "3-6 Years",
|
|
||||||
"hr": { "awake": "65-110", "sleeping": "55-100" },
|
|
||||||
"rr": "20-25",
|
|
||||||
"sbp": "95-110",
|
|
||||||
"dbp": "60-75",
|
|
||||||
"temp": "36.0-37.5",
|
|
||||||
"weight": "14-20 kg",
|
|
||||||
"spo2": ">95%",
|
|
||||||
"notes": [
|
|
||||||
"Annual BP screening recommended from age 3",
|
|
||||||
"Normal BP <90th percentile for age, sex, and height",
|
|
||||||
"Elevated BP: 90th to <95th percentile (or 120/80 if lower)",
|
|
||||||
"Estimated weight: 2 x (age in years) + 8",
|
|
||||||
"ETT size (uncuffed): (age/4) + 4"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"6-12yr": {
|
|
||||||
"label": "6-12 Years",
|
|
||||||
"hr": { "awake": "60-95", "sleeping": "50-85" },
|
|
||||||
"rr": "14-22",
|
|
||||||
"sbp": "100-120",
|
|
||||||
"dbp": "60-75",
|
|
||||||
"temp": "36.0-37.5",
|
|
||||||
"weight": "20-40 kg",
|
|
||||||
"spo2": ">95%",
|
|
||||||
"notes": [
|
|
||||||
"Resting HR >95 or <60 warrants evaluation",
|
|
||||||
"BP should be measured at every clinical encounter",
|
|
||||||
"Stage 1 HTN: >=95th percentile on 3 separate occasions",
|
|
||||||
"Estimated weight: 3 x (age in years) + 7",
|
|
||||||
"ETT size (cuffed): (age/4) + 3.5"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
">12yr": {
|
|
||||||
"label": ">12 Years",
|
|
||||||
"hr": { "awake": "55-85", "sleeping": "45-75" },
|
|
||||||
"rr": "12-18",
|
|
||||||
"sbp": "110-135",
|
|
||||||
"dbp": "65-85",
|
|
||||||
"temp": "36.0-37.5",
|
|
||||||
"weight": "40-80 kg",
|
|
||||||
"spo2": ">95%",
|
|
||||||
"notes": [
|
|
||||||
"Vital signs approach adult values",
|
|
||||||
"From age 13: use adult BP thresholds (AAP 2017)",
|
|
||||||
"Normal: <120/<80 mmHg; Elevated: 120-129/<80 mmHg",
|
|
||||||
"Stage 1 HTN: 130-139/80-89 mmHg; Stage 2 HTN: >=140/>=90 mmHg",
|
|
||||||
"Orthostatic vitals: measure lying, sitting, standing if dizzy",
|
|
||||||
"Athletic bradycardia (HR 45-60) may be normal in trained adolescents"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -11,19 +11,13 @@
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
|
||||||
integrity="sha384-/o6I2CkkWC//PSjvWC/eYN7l3xM3tJm8ZzVkCOfp//W05QcE3mlGskpoHB6XqI+B" crossorigin="anonymous" referrerpolicy="no-referrer">
|
integrity="sha384-/o6I2CkkWC//PSjvWC/eYN7l3xM3tJm8ZzVkCOfp//W05QcE3mlGskpoHB6XqI+B" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||||
<!-- Explicit render mode: the register/forgot widgets live inside forms that
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
start hidden, and Turnstile's implicit auto-render does not reliably
|
|
||||||
complete a challenge inside a display:none container. auth.js renders
|
|
||||||
each widget the first time its form is shown. -->
|
|
||||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"
|
||||||
integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
|
integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
|
||||||
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
|
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
|
||||||
<link rel="stylesheet" href="/vendor/katex/katex.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
|
||||||
<script src="/vendor/marked/marked.umd.js" defer></script>
|
<script src="https://cdn.jsdelivr.net/npm/marked@14.1.3/marked.min.js" defer></script>
|
||||||
<script src="/vendor/markdown-it/markdown-it.min.js" defer></script>
|
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" defer></script>
|
||||||
<script src="/vendor/katex/katex.min.js" defer></script>
|
|
||||||
<script src="/vendor/mathjax/tex-mml-chtml.js" defer></script>
|
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<meta name="theme-color" content="#2563eb">
|
<meta name="theme-color" content="#2563eb">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
|
@ -74,6 +68,7 @@
|
||||||
<label>2FA Code</label>
|
<label>2FA Code</label>
|
||||||
<input type="text" id="login-totp" placeholder="6-digit code" maxlength="6">
|
<input type="text" id="login-totp" placeholder="6-digit code" maxlength="6">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cf-turnstile" id="turnstile-login" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
|
||||||
<button type="submit" class="btn-auth" id="btn-local-login">Sign In</button>
|
<button type="submit" class="btn-auth" id="btn-local-login">Sign In</button>
|
||||||
<div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;">
|
<div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;">
|
||||||
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span>
|
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span>
|
||||||
|
|
@ -107,7 +102,7 @@
|
||||||
<label>Password (8+ characters)</label>
|
<label>Password (8+ characters)</label>
|
||||||
<input type="password" id="reg-password" required minlength="8" placeholder="••••••••">
|
<input type="password" id="reg-password" required minlength="8" placeholder="••••••••">
|
||||||
</div>
|
</div>
|
||||||
<div id="turnstile-register" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></div>
|
<div class="cf-turnstile" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
|
||||||
<button type="submit" class="btn-auth">Create Account</button>
|
<button type="submit" class="btn-auth">Create Account</button>
|
||||||
<div class="auth-links">
|
<div class="auth-links">
|
||||||
<a href="#" id="show-login">Back to sign in</a>
|
<a href="#" id="show-login">Back to sign in</a>
|
||||||
|
|
@ -121,7 +116,7 @@
|
||||||
<label>Email</label>
|
<label>Email</label>
|
||||||
<input type="email" id="forgot-email" required placeholder="your@email.com">
|
<input type="email" id="forgot-email" required placeholder="your@email.com">
|
||||||
</div>
|
</div>
|
||||||
<div id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></div>
|
<div class="cf-turnstile" id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
|
||||||
<button type="submit" class="btn-auth">Send Reset Link</button>
|
<button type="submit" class="btn-auth">Send Reset Link</button>
|
||||||
<div class="auth-links">
|
<div class="auth-links">
|
||||||
<a href="#" id="show-login-2">Back to sign in</a>
|
<a href="#" id="show-login-2">Back to sign in</a>
|
||||||
|
|
@ -134,7 +129,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="apk-download-link" style="text-align:center;margin:14px 0 0;font-size:13px;">
|
<div id="apk-download-link" style="text-align:center;margin:14px 0 0;font-size:13px;">
|
||||||
<a href="https://git.danvics.com/danvics/pediatric-ai-scribe-v3/releases/latest" target="_blank" rel="noopener" style="color:#2563eb;text-decoration:none;font-weight:500;">
|
<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;">
|
||||||
<i class="fas fa-mobile-screen"></i> Download Android app (APK)
|
<i class="fas fa-mobile-screen"></i> Download Android app (APK)
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -175,6 +170,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
<div class="model-selector">
|
||||||
|
<label><i class="fas fa-robot"></i></label>
|
||||||
|
<select id="global-model-select"></select>
|
||||||
|
<span id="model-cost-badge" class="cost-badge" style="display:none;"></span>
|
||||||
|
</div>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
<button id="btn-settings" class="btn-header" title="Settings">
|
<button id="btn-settings" class="btn-header" title="Settings">
|
||||||
<i class="fas fa-cog"></i>
|
<i class="fas fa-cog"></i>
|
||||||
|
|
@ -391,9 +391,9 @@
|
||||||
<ul style="font-size:12.5px;color:var(--g700);line-height:1.8;padding-left:18px;margin:0 0 12px;">
|
<ul style="font-size:12.5px;color:var(--g700);line-height:1.8;padding-left:18px;margin:0 0 12px;">
|
||||||
<li>Multiple AI providers with HIPAA-compliant options (BAA-covered)</li>
|
<li>Multiple AI providers with HIPAA-compliant options (BAA-covered)</li>
|
||||||
<li>Multiple transcription engines including medical-specific models</li>
|
<li>Multiple transcription engines including medical-specific models</li>
|
||||||
<li>Configurable speech-to-text with server-side providers</li>
|
<li>Browser Whisper for fully offline, private transcription</li>
|
||||||
<li>Text-to-speech for reading notes aloud</li>
|
<li>Text-to-speech for reading notes aloud</li>
|
||||||
<li>AI can follow your saved note templates and formatting preferences</li>
|
<li>AI learns from your edits over time (correction tracking)</li>
|
||||||
<li>Customizable AI prompts for each note type</li>
|
<li>Customizable AI prompts for each note type</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|
@ -473,41 +473,43 @@
|
||||||
integrity="sha384-JUh163oCRItcbPme8pYnROHQMC6fNKTBWtRG3I3I0erJkzNgL7uxKlNwcrcFKeqF"
|
integrity="sha384-JUh163oCRItcbPme8pYnROHQMC6fNKTBWtRG3I3I0erJkzNgL7uxKlNwcrcFKeqF"
|
||||||
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
|
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
|
||||||
<script defer src="/js/milestonesData.js"></script>
|
<script defer src="/js/milestonesData.js"></script>
|
||||||
<script type="module" src="/js/audioBackup.js"></script>
|
<script defer src="/js/pediatricScheduleData.js"></script>
|
||||||
<script type="module" src="/js/speechRecognition.js"></script>
|
<script defer src="/js/audioBackup.js"></script>
|
||||||
<script type="module" src="/js/transcriptionSettings.js"></script>
|
<script defer src="/js/browserWhisper.js"></script>
|
||||||
<script type="module" src="/js/voicePreferences.js"></script>
|
<script defer src="/js/speechRecognition.js"></script>
|
||||||
|
<script defer src="/js/transcriptionSettings.js"></script>
|
||||||
|
<script defer src="/js/voicePreferences.js"></script>
|
||||||
<script defer src="/js/app.js?v=7.1.3"></script>
|
<script defer src="/js/app.js?v=7.1.3"></script>
|
||||||
<script type="module" src="/js/ui-state.js"></script>
|
<script defer src="/js/ui-state.js"></script>
|
||||||
<script type="module" src="/js/secureStorage.js"></script>
|
<script defer src="/js/secureStorage.js"></script>
|
||||||
<script type="module" src="/js/authFetch.js"></script>
|
<script defer src="/js/authFetch.js"></script>
|
||||||
<script defer src="/js/auth.js"></script>
|
<script defer src="/js/auth.js"></script>
|
||||||
<script defer src="/js/liveEncounter.js"></script>
|
<script defer src="/js/liveEncounter.js"></script>
|
||||||
<script defer src="/js/voiceDictation.js"></script>
|
<script defer src="/js/voiceDictation.js"></script>
|
||||||
<script type="module" src="/js/hospitalCourse.js"></script>
|
<script defer src="/js/hospitalCourse.js"></script>
|
||||||
<script type="module" src="/js/chartReview.js"></script>
|
<script defer src="/js/chartReview.js"></script>
|
||||||
<script type="module" src="/js/soap.js"></script>
|
<script defer src="/js/soap.js"></script>
|
||||||
<script type="module" src="/js/milestones.js"></script>
|
<script defer src="/js/milestones.js"></script>
|
||||||
<script type="module" src="/js/peGuide.js"></script>
|
<script defer src="/js/peGuide.js"></script>
|
||||||
<script type="module" src="/js/extensions.js"></script>
|
<script defer src="/js/extensions.js"></script>
|
||||||
<script type="module" src="/js/notes.js"></script>
|
<script defer src="/js/notes.js"></script>
|
||||||
<script type="module" src="/js/diagrams.js"></script>
|
<script defer src="/js/diagrams.js"></script>
|
||||||
<script type="module" src="/js/clinicalAssistant.js"></script>
|
<script type="module" src="/js/clinicalAssistant.js"></script>
|
||||||
<script type="module" src="/js/nextcloud.js"></script>
|
<script defer src="/js/nextcloud.js"></script>
|
||||||
<script type="module" src="/js/wellVisit.js"></script>
|
<script defer src="/js/wellVisit.js"></script>
|
||||||
<script defer src="/js/shadess.js"></script>
|
<script defer src="/js/shadess.js"></script>
|
||||||
<script type="module" src="/js/sickVisit.js"></script>
|
<script defer src="/js/sickVisit.js"></script>
|
||||||
<script type="module" src="/js/ed-encounters.js"></script>
|
<script defer src="/js/ed-encounters.js"></script>
|
||||||
<script defer src="/js/encounters.js"></script>
|
<script defer src="/js/encounters.js"></script>
|
||||||
<script type="module" src="/js/memories.js"></script>
|
<script defer src="/js/memories.js"></script>
|
||||||
<script type="module" src="/js/admin-docs.js"></script>
|
<script defer src="/js/admin-docs.js"></script>
|
||||||
<script type="module" src="/js/documents.js"></script>
|
<script defer src="/js/documents.js"></script>
|
||||||
<script defer src="/js/calc-math.js"></script>
|
<script defer src="/js/calc-math.js"></script>
|
||||||
<script type="module" src="/js/drugs-loader.js"></script>
|
<script defer src="/js/drugs-loader.js"></script>
|
||||||
<script type="module" src="/js/calculators.js"></script>
|
<script defer src="/js/calculators.js"></script>
|
||||||
<script type="module" src="/js/bedside/index.js"></script>
|
<script type="module" src="/js/bedside/index.js"></script>
|
||||||
<script type="module" src="/js/learningHub.js"></script>
|
<script defer src="/js/learningHub.js"></script>
|
||||||
<script type="module" src="/js/admin.js?v=7.1.3"></script>
|
<script defer src="/js/admin.js?v=7.1.3"></script>
|
||||||
|
|
||||||
<!-- ═══════════ IMAGE LIGHTBOX (global overlay — triggered by any
|
<!-- ═══════════ IMAGE LIGHTBOX (global overlay — triggered by any
|
||||||
[data-img-src] button in any tab; lives here so it exists in the
|
[data-img-src] button in any tab; lives here so it exists in the
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,14 @@
|
||||||
// auth.js after login when user.role === 'admin'.
|
// auth.js after login when user.role === 'admin'.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
var _inited = false;
|
var _inited = false;
|
||||||
var _treeLoaded = false;
|
var _treeLoaded = false;
|
||||||
var _tree = [];
|
var _tree = [];
|
||||||
var _flatFiles = []; // flat list of {name, path, parent} for filter
|
var _flatFiles = []; // flat list of {name, path, parent} for filter
|
||||||
var _expanded = {}; // map of dir-path → bool, persisted in UIState
|
var _expanded = {}; // map of dir-path → bool, persisted in UIState
|
||||||
var _currentPath = '';
|
|
||||||
|
|
||||||
function $(id) { return document.getElementById(id); }
|
function $(id) { return document.getElementById(id); }
|
||||||
function escHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
function escHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||||
|
|
@ -108,123 +110,6 @@
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// marked no longer guarantees heading ids across versions, and the docs
|
|
||||||
// reader is an internal scroll container. Add stable GitHub-style ids and
|
|
||||||
// handle same-page #toc links by scrolling the reader, not the window.
|
|
||||||
function slugHeading(text) {
|
|
||||||
return String(text || '')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\s/g, '-')
|
|
||||||
.replace(/[^a-z0-9_-]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareDocAnchors(body) {
|
|
||||||
if (!body) return;
|
|
||||||
var seen = {};
|
|
||||||
body.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(function (h) {
|
|
||||||
var base = slugHeading(h.textContent || '') || h.id;
|
|
||||||
if (!base) return;
|
|
||||||
var id = base;
|
|
||||||
var n = seen[base] || 0;
|
|
||||||
if (n) id = base + '-' + n;
|
|
||||||
seen[base] = n + 1;
|
|
||||||
h.id = id;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareDocLinks(body) {
|
|
||||||
if (!body) return;
|
|
||||||
body.querySelectorAll('a[href]').forEach(function (link) {
|
|
||||||
var href = link.getAttribute('href') || '';
|
|
||||||
if (href.charAt(0) === '#') {
|
|
||||||
link.dataset.docHash = href;
|
|
||||||
link.classList.add('docs-anchor-link');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (/\.md(#.*)?$/i.test(href)) {
|
|
||||||
var parts = href.split('#');
|
|
||||||
link.dataset.docFile = resolveDocPath(parts[0] || _currentPath);
|
|
||||||
if (parts[1]) link.dataset.docHash = '#' + parts[1];
|
|
||||||
link.classList.add('docs-anchor-link');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (link.hash) {
|
|
||||||
try {
|
|
||||||
var u = new URL(link.href, window.location.href);
|
|
||||||
if (u.origin !== window.location.origin || u.pathname !== window.location.pathname) return;
|
|
||||||
link.dataset.docHash = u.hash;
|
|
||||||
link.classList.add('docs-anchor-link');
|
|
||||||
} catch (_) { return; }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDocPath(href) {
|
|
||||||
if (!href) return _currentPath;
|
|
||||||
if (href.charAt(0) === '/') return href.replace(/^\/+/, '');
|
|
||||||
var base = _currentPath.split('/');
|
|
||||||
base.pop();
|
|
||||||
href.split('/').forEach(function (part) {
|
|
||||||
if (!part || part === '.') return;
|
|
||||||
if (part === '..') base.pop();
|
|
||||||
else base.push(part);
|
|
||||||
});
|
|
||||||
return base.join('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollReaderToHash(hash) {
|
|
||||||
var body = $('docs-reader-body');
|
|
||||||
var reader = $('docs-reader');
|
|
||||||
if (!body || !reader || !hash) return false;
|
|
||||||
var rawId = String(hash).replace(/^#/, '');
|
|
||||||
var id;
|
|
||||||
try { id = decodeURIComponent(rawId); } catch (_) { id = rawId; }
|
|
||||||
if (!id) return false;
|
|
||||||
var target = document.getElementById(id);
|
|
||||||
if (!target || !body.contains(target)) {
|
|
||||||
target = Array.prototype.slice.call(body.querySelectorAll('h1,h2,h3,h4,h5,h6')).find(function (h) {
|
|
||||||
return slugHeading(h.textContent || '') === id;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!target || !body.contains(target)) return false;
|
|
||||||
var readerBox = reader.getBoundingClientRect();
|
|
||||||
var targetBox = target.getBoundingClientRect();
|
|
||||||
reader.scrollTop += targetBox.top - readerBox.top - 8;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDocAnchorClick(e) {
|
|
||||||
var body = $('docs-reader-body');
|
|
||||||
var link = e.target.closest('a, [data-doc-hash], [data-doc-file]');
|
|
||||||
if (!link || !body || !body.contains(link)) return false;
|
|
||||||
var href = link.dataset.docHash || link.getAttribute('href') || '';
|
|
||||||
var file = link.dataset.docFile || '';
|
|
||||||
if (!file && href && /\.md(#.*)?$/i.test(href)) {
|
|
||||||
var parts = href.split('#');
|
|
||||||
file = resolveDocPath(parts[0] || _currentPath);
|
|
||||||
href = parts[1] ? '#' + parts[1] : '';
|
|
||||||
}
|
|
||||||
if (!file && (!href || href.charAt(0) !== '#')) return false;
|
|
||||||
e.preventDefault();
|
|
||||||
if (file && file !== _currentPath) {
|
|
||||||
loadFile(file, href);
|
|
||||||
markActive(file);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
requestAnimationFrame(function () {
|
|
||||||
if (scrollReaderToHash(href)) {
|
|
||||||
try { history.replaceState(null, '', href); } catch (_) {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDocAnchorKeydown(e) {
|
|
||||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
||||||
if (handleDocAnchorClick(e)) e.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadTree() {
|
function loadTree() {
|
||||||
if (_treeLoaded) return;
|
if (_treeLoaded) return;
|
||||||
fetch('/api/admin/docs/tree', { headers: getAuthHeaders() })
|
fetch('/api/admin/docs/tree', { headers: getAuthHeaders() })
|
||||||
|
|
@ -257,11 +142,10 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFile(relPath, hash) {
|
function loadFile(relPath) {
|
||||||
var body = $('docs-reader-body');
|
var body = $('docs-reader-body');
|
||||||
var meta = $('docs-reader-meta');
|
var meta = $('docs-reader-meta');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
_currentPath = relPath;
|
|
||||||
body.innerHTML = '<div class="docs-loading">Loading…</div>';
|
body.innerHTML = '<div class="docs-loading">Loading…</div>';
|
||||||
fetch('/api/admin/docs/file?path=' + encodeURIComponent(relPath), { headers: getAuthHeaders() })
|
fetch('/api/admin/docs/file?path=' + encodeURIComponent(relPath), { headers: getAuthHeaders() })
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
|
|
@ -271,8 +155,6 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
body.innerHTML = data.html || '';
|
body.innerHTML = data.html || '';
|
||||||
prepareDocAnchors(body);
|
|
||||||
prepareDocLinks(body);
|
|
||||||
if (meta) {
|
if (meta) {
|
||||||
meta.textContent = relPath + ' • ' + (data.bytes != null ? (data.bytes + ' bytes') : '');
|
meta.textContent = relPath + ' • ' + (data.bytes != null ? (data.bytes + ' bytes') : '');
|
||||||
}
|
}
|
||||||
|
|
@ -281,8 +163,6 @@
|
||||||
// Scroll content area to top so deep-link readers don't land mid-doc
|
// Scroll content area to top so deep-link readers don't land mid-doc
|
||||||
var reader = $('docs-reader');
|
var reader = $('docs-reader');
|
||||||
if (reader) reader.scrollTop = 0;
|
if (reader) reader.scrollTop = 0;
|
||||||
var targetHash = hash || window.location.hash;
|
|
||||||
if (targetHash) setTimeout(function () { scrollReaderToHash(targetHash); }, 0);
|
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
body.innerHTML = '<p style="color:var(--red);">' + escHtml(err.message || String(err)) + '</p>';
|
body.innerHTML = '<p style="color:var(--red);">' + escHtml(err.message || String(err)) + '</p>';
|
||||||
|
|
@ -303,7 +183,6 @@
|
||||||
// ── Wire events ────────────────────────────────────────────────────
|
// ── Wire events ────────────────────────────────────────────────────
|
||||||
function init() {
|
function init() {
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
if (handleDocAnchorClick(e)) return;
|
|
||||||
var fileBtn = e.target.closest('.docs-file-btn');
|
var fileBtn = e.target.closest('.docs-file-btn');
|
||||||
if (fileBtn) {
|
if (fileBtn) {
|
||||||
var p = fileBtn.dataset.file;
|
var p = fileBtn.dataset.file;
|
||||||
|
|
@ -327,17 +206,9 @@
|
||||||
arrow.classList.toggle('fa-chevron-right', !willOpen);
|
arrow.classList.toggle('fa-chevron-right', !willOpen);
|
||||||
}
|
}
|
||||||
persistExpanded();
|
persistExpanded();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var body = $('docs-reader-body');
|
|
||||||
if (body && !body.dataset.anchorsWired) {
|
|
||||||
body.dataset.anchorsWired = '1';
|
|
||||||
body.addEventListener('click', handleDocAnchorClick);
|
|
||||||
body.addEventListener('keydown', handleDocAnchorKeydown);
|
|
||||||
}
|
|
||||||
|
|
||||||
var filter = $('docs-filter');
|
var filter = $('docs-filter');
|
||||||
if (filter) {
|
if (filter) {
|
||||||
var t = null;
|
var t = null;
|
||||||
|
|
@ -359,3 +230,4 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Admin docs viewer loaded');
|
console.log('Admin docs viewer loaded');
|
||||||
|
})();
|
||||||
|
|
|
||||||
|
|
@ -2,36 +2,9 @@
|
||||||
// ADMIN.JS — Admin panel: users, settings, stats
|
// ADMIN.JS — Admin panel: users, settings, stats
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
function adminEscapeHtml(str) {
|
(function() {
|
||||||
if (!str) return '';
|
|
||||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function adminTableMessage(colspan, color, text) {
|
var loaded = false;
|
||||||
return '<tr><td colspan="' + colspan + '" style="text-align:center;color:' + color + ';padding:20px;">' + adminEscapeHtml(text) + '</td></tr>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function adminSetButtonText(btn, text, disabled) {
|
|
||||||
if (!btn) return;
|
|
||||||
btn.textContent = text;
|
|
||||||
btn.disabled = !!disabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
function adminSetButtonHtml(btn, html, disabled) {
|
|
||||||
if (!btn) return;
|
|
||||||
btn.innerHTML = html;
|
|
||||||
btn.disabled = !!disabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
function adminFlashButtonBackground(btn, color) {
|
|
||||||
if (!btn) return;
|
|
||||||
btn.style.background = color || '';
|
|
||||||
setTimeout(function() { if (btn) btn.style.background = ''; }, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
let loaded = false;
|
|
||||||
|
|
||||||
// Load admin panel when admin tab is activated
|
// Load admin panel when admin tab is activated
|
||||||
document.addEventListener('tabChanged', function(e) {
|
document.addEventListener('tabChanged', function(e) {
|
||||||
|
|
@ -113,15 +86,15 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
function loadUsers() {
|
function loadUsers() {
|
||||||
var tbody = document.getElementById('admin-users-body');
|
var tbody = document.getElementById('admin-users-body');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
tbody.innerHTML = adminTableMessage(5, 'var(--g400)', 'Loading...');
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--g400);padding:20px;">Loading...</td></tr>';
|
||||||
|
|
||||||
fetch('/api/admin/users', { headers: getAuthHeaders() })
|
fetch('/api/admin/users', { headers: getAuthHeaders() })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data.success) { tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Failed to load users'); return; }
|
if (!data.success) { tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--red);padding:20px;">Failed to load users</td></tr>'; return; }
|
||||||
renderUsers(data.users || []);
|
renderUsers(data.users || []);
|
||||||
})
|
})
|
||||||
.catch(function() { tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Request failed'); });
|
.catch(function() { tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--red);padding:20px;">Request failed</td></tr>'; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUsers(users) {
|
function renderUsers(users) {
|
||||||
|
|
@ -131,7 +104,7 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
var currentUser = JSON.parse(localStorage.getItem('ped_scribe_user') || '{}');
|
var currentUser = JSON.parse(localStorage.getItem('ped_scribe_user') || '{}');
|
||||||
|
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
tbody.innerHTML = adminTableMessage(5, 'var(--g400)', 'No users found');
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--g400);padding:20px;">No users found</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,16 +253,19 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
.catch(function() { showToast('Request failed', 'error'); });
|
.catch(function() { showToast('Request failed', 'error'); });
|
||||||
}
|
}
|
||||||
|
|
||||||
const esc = adminEscapeHtml;
|
function esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
}
|
})();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// ADMIN CMS — Announcements, Feature Flags, Email, AI Prompts
|
// ADMIN CMS — Announcements, Feature Flags, Email, AI Prompts
|
||||||
// ============================================================
|
// ============================================================
|
||||||
{
|
(function() {
|
||||||
|
|
||||||
let cmsLoaded = false;
|
var cmsLoaded = false;
|
||||||
|
|
||||||
// Load CMS when admin tab is opened (via tabChanged event or click)
|
// Load CMS when admin tab is opened (via tabChanged event or click)
|
||||||
document.addEventListener('tabChanged', function(e) {
|
document.addEventListener('tabChanged', function(e) {
|
||||||
|
|
@ -681,15 +657,15 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
})();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// ADMIN CLINICAL ASSISTANT SETTINGS
|
// ADMIN CLINICAL ASSISTANT SETTINGS
|
||||||
// ============================================================
|
// ============================================================
|
||||||
{
|
(function() {
|
||||||
let loaded = false;
|
var loaded = false;
|
||||||
const defaults = {
|
var defaults = {
|
||||||
behavior: 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting or too vague, answer briefly and ask what they want to look up. Synthesize across sources and cite factual claims with the exact provided source numbers like [1]. Do not invent, renumber, merge, or move citations.'
|
behavior: 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting or too vague, answer briefly and ask what they want to look up. Synthesize across sources, cite claims with numbered citations like [1], and list sources at the bottom by title/resource and page. Do not invent citations.'
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('tabChanged', function(e) {
|
document.addEventListener('tabChanged', function(e) {
|
||||||
|
|
@ -701,11 +677,8 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin();
|
if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin();
|
||||||
if (e.target.closest('#btn-test-assistant-chat-model')) testAssistantChatModel();
|
if (e.target.closest('#btn-test-assistant-chat-model')) testAssistantChatModel();
|
||||||
if (e.target.closest('#btn-regenerate-assistant-prompt-pool')) regenerateAssistantPromptPool();
|
|
||||||
if (e.target.closest('#btn-restore-assistant-prompt-pool')) restoreAssistantPromptPool();
|
|
||||||
if (e.target.closest('#btn-refresh-assistant-image-models')) loadAssistantImageModels();
|
if (e.target.closest('#btn-refresh-assistant-image-models')) loadAssistantImageModels();
|
||||||
if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel();
|
if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel();
|
||||||
if (e.target.closest('#btn-use-custom-assistant-image-model')) useCustomAssistantImageModel();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function loadAssistantAdmin() {
|
function loadAssistantAdmin() {
|
||||||
|
|
@ -743,7 +716,6 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
}
|
}
|
||||||
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
|
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
|
||||||
loadAssistantImageModels();
|
loadAssistantImageModels();
|
||||||
loadAssistantPromptPoolStatus();
|
|
||||||
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
|
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
|
||||||
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
|
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
|
||||||
setValue('assistant-system-behavior', cfg['clinical_assistant.system_behavior'] || defaults.behavior);
|
setValue('assistant-system-behavior', cfg['clinical_assistant.system_behavior'] || defaults.behavior);
|
||||||
|
|
@ -813,91 +785,6 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
return match ? match[1] : '';
|
return match ? match[1] : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadAssistantPromptPoolStatus() {
|
|
||||||
var result = document.getElementById('assistant-prompt-pool-status');
|
|
||||||
if (result) result.textContent = 'Checking prompt pool...';
|
|
||||||
fetch('/api/admin/clinical-assistant/prompt-pool', { headers: getAuthHeaders() })
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
|
||||||
if (!data.success) throw new Error(data.error || 'Prompt pool status failed');
|
|
||||||
renderAssistantPromptPoolStatus(data.meta);
|
|
||||||
renderAssistantPromptPoolSnapshots(data.snapshots || []);
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
if (result) result.textContent = err.message;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function regenerateAssistantPromptPool() {
|
|
||||||
var result = document.getElementById('assistant-prompt-pool-status');
|
|
||||||
var btn = document.getElementById('btn-regenerate-assistant-prompt-pool');
|
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
if (result) result.textContent = 'Regenerating prompt pool. This can take several minutes...';
|
|
||||||
fetch('/api/admin/clinical-assistant/prompt-pool/regenerate', {
|
|
||||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({})
|
|
||||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
|
||||||
if (!data.success) throw new Error(data.error || 'Prompt pool regeneration failed');
|
|
||||||
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now() });
|
|
||||||
renderAssistantPromptPoolSnapshots(data.snapshots || []);
|
|
||||||
showToast('Prompt pool regenerated', 'success');
|
|
||||||
}).catch(function(err) {
|
|
||||||
if (result) result.textContent = err.message;
|
|
||||||
showToast(err.message, 'error');
|
|
||||||
}).finally(function() {
|
|
||||||
if (btn) btn.disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAssistantPromptPoolStatus(meta) {
|
|
||||||
var result = document.getElementById('assistant-prompt-pool-status');
|
|
||||||
if (!result) return;
|
|
||||||
if (!meta) {
|
|
||||||
result.textContent = 'No generated pool found. The assistant will use indexed-topic fallback until you generate one.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var generated = meta.generatedAt ? new Date(meta.generatedAt).toLocaleString() : 'unknown time';
|
|
||||||
result.textContent = 'Generated pool: ' + (meta.count || 0) + ' prompts, target ' + (meta.target || '?') + ', generated ' + generated + (meta.restoredFrom ? ', restored from snapshot #' + meta.restoredFrom : '') + '.';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAssistantPromptPoolSnapshots(snapshots) {
|
|
||||||
var select = document.getElementById('assistant-prompt-pool-snapshots');
|
|
||||||
if (!select) return;
|
|
||||||
select.innerHTML = '';
|
|
||||||
if (!snapshots.length) {
|
|
||||||
var empty = document.createElement('option');
|
|
||||||
empty.value = '';
|
|
||||||
empty.textContent = 'No saved snapshots';
|
|
||||||
select.appendChild(empty);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
snapshots.forEach(function(s) {
|
|
||||||
var opt = document.createElement('option');
|
|
||||||
opt.value = s.id;
|
|
||||||
var date = s.created_at ? new Date(s.created_at).toLocaleString() : 'unknown time';
|
|
||||||
opt.textContent = '#' + s.id + ' - ' + (s.count || 0) + ' prompts - ' + date + (s.restored_from ? ' (restored from #' + s.restored_from + ')' : '');
|
|
||||||
select.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreAssistantPromptPool() {
|
|
||||||
var select = document.getElementById('assistant-prompt-pool-snapshots');
|
|
||||||
var id = select ? select.value : '';
|
|
||||||
var result = document.getElementById('assistant-prompt-pool-status');
|
|
||||||
if (!id) { showToast('Select a prompt pool snapshot', 'error'); return; }
|
|
||||||
if (result) result.textContent = 'Restoring prompt pool snapshot #' + id + '...';
|
|
||||||
fetch('/api/admin/clinical-assistant/prompt-pool/restore', {
|
|
||||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ id: Number(id) })
|
|
||||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
|
||||||
if (!data.success) throw new Error(data.error || 'Prompt pool restore failed');
|
|
||||||
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now(), restoredFrom: id });
|
|
||||||
renderAssistantPromptPoolSnapshots(data.snapshots || []);
|
|
||||||
showToast('Prompt pool restored', 'success');
|
|
||||||
}).catch(function(err) {
|
|
||||||
if (result) result.textContent = err.message;
|
|
||||||
showToast(err.message, 'error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function testAssistantImageModel() {
|
function testAssistantImageModel() {
|
||||||
var model = getValue('assistant-image-model') || 'openai-gpt-image-1';
|
var model = getValue('assistant-image-model') || 'openai-gpt-image-1';
|
||||||
var result = document.getElementById('assistant-image-test-result');
|
var result = document.getElementById('assistant-image-test-result');
|
||||||
|
|
@ -907,7 +794,7 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||||
if (!data.success) throw new Error(data.error || 'Image test failed');
|
if (!data.success) throw new Error(data.error || 'Image test failed');
|
||||||
var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
|
var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
|
||||||
if (result) result.innerHTML = 'Image model OK (' + data.duration + ' ms)' + (src ? '<div style="margin-top:8px;"><img src="' + escAssistant(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="' + src + '" alt="test image" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : '');
|
||||||
showToast('Image model works', 'success');
|
showToast('Image model works', 'success');
|
||||||
}).catch(function(err) {
|
}).catch(function(err) {
|
||||||
if (result) result.textContent = err.message;
|
if (result) result.textContent = err.message;
|
||||||
|
|
@ -915,22 +802,6 @@ 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() {
|
function saveAssistantAdmin() {
|
||||||
var status = document.getElementById('assistant-admin-status');
|
var status = document.getElementById('assistant-admin-status');
|
||||||
if (status) status.textContent = 'Saving...';
|
if (status) status.textContent = 'Saving...';
|
||||||
|
|
@ -959,13 +830,13 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
}
|
}
|
||||||
function getValue(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; }
|
function getValue(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; }
|
||||||
function setValue(id, value) { var el = document.getElementById(id); if (el) el.value = value; }
|
function setValue(id, value) { var el = document.getElementById(id); if (el) el.value = value; }
|
||||||
const escAssistant = adminEscapeHtml;
|
function escAssistant(str) { return String(str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||||
}
|
})();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// ADMIN MODEL MANAGEMENT — Discover, search, enable/disable, custom models
|
// ADMIN MODEL MANAGEMENT — Discover, search, enable/disable, custom models
|
||||||
// ============================================================
|
// ============================================================
|
||||||
{
|
(function() {
|
||||||
|
|
||||||
document.addEventListener('tabChanged', function(e) {
|
document.addEventListener('tabChanged', function(e) {
|
||||||
if (e.detail && e.detail.tab === 'admin') {
|
if (e.detail && e.detail.tab === 'admin') {
|
||||||
|
|
@ -992,7 +863,10 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const esc = adminEscapeHtml;
|
function esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
function loadAdminModels() {
|
function loadAdminModels() {
|
||||||
fetch('/api/admin/config/models', { headers: getAuthHeaders() })
|
fetch('/api/admin/config/models', { headers: getAuthHeaders() })
|
||||||
|
|
@ -1277,7 +1151,7 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
function testModel(modelId, btn) {
|
function testModel(modelId, btn) {
|
||||||
if (!modelId) return;
|
if (!modelId) return;
|
||||||
var origText = btn ? btn.textContent : 'Test';
|
var origText = btn ? btn.textContent : 'Test';
|
||||||
adminSetButtonText(btn, '...', true);
|
if (btn) { btn.textContent = '...'; btn.disabled = true; }
|
||||||
|
|
||||||
fetch('/api/admin/config/models/test', {
|
fetch('/api/admin/config/models/test', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -1286,7 +1160,7 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
adminSetButtonText(btn, origText, false);
|
if (btn) { btn.textContent = origText; btn.disabled = false; }
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast('"' + (data.response || '?') + '" — ' + modelId + ' (' + (data.duration || 0) + 'ms)', 'success');
|
showToast('"' + (data.response || '?') + '" — ' + modelId + ' (' + (data.duration || 0) + 'ms)', 'success');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1294,17 +1168,17 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
adminSetButtonText(btn, origText, false);
|
if (btn) { btn.textContent = origText; btn.disabled = false; }
|
||||||
showToast('Request failed', 'error');
|
showToast('Request failed', 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
})();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// ADMIN TTS MANAGEMENT
|
// ADMIN TTS MANAGEMENT
|
||||||
// ============================================================
|
// ============================================================
|
||||||
{
|
(function() {
|
||||||
document.addEventListener('tabChanged', function(e) {
|
document.addEventListener('tabChanged', function(e) {
|
||||||
if (e.detail && e.detail.tab === 'admin') loadTTSConfig();
|
if (e.detail && e.detail.tab === 'admin') loadTTSConfig();
|
||||||
});
|
});
|
||||||
|
|
@ -1320,7 +1194,10 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
if (e.target.id === 'admin-tts-search' && e.key === 'Enter') { e.preventDefault(); discoverTTS(); }
|
if (e.target.id === 'admin-tts-search' && e.key === 'Enter') { e.preventDefault(); discoverTTS(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
const esc = adminEscapeHtml;
|
function esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
function loadTTSConfig() {
|
function loadTTSConfig() {
|
||||||
fetch('/api/admin/config/tts', { headers: getAuthHeaders() })
|
fetch('/api/admin/config/tts', { headers: getAuthHeaders() })
|
||||||
|
|
@ -1380,12 +1257,12 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
}
|
}
|
||||||
var items = data.voices || [];
|
var items = data.voices || [];
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No voices/models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
|
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No voices found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' voices/models (provider: ' + esc(data.provider) + ')</p>' +
|
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' voices (provider: ' + esc(data.provider) + ')</p>' +
|
||||||
items.slice(0, 100).map(function(v) {
|
items.slice(0, 100).map(function(v) {
|
||||||
var isModel = v.kind === 'model' || (v.source || '').indexOf('gateway') !== -1 || (v.source || '').indexOf('builtin-model') !== -1 || (v.source || '').indexOf('configured-model') !== -1;
|
var isModel = (v.source || '').indexOf('gateway') !== -1 || (v.source || '').indexOf('builtin-model') !== -1;
|
||||||
var setType = isModel ? 'model' : 'voice';
|
var setType = isModel ? 'model' : 'voice';
|
||||||
var badge = isModel ? '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--blue);color:white;margin-left:4px;">MODEL</span>' : '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--green);color:white;margin-left:4px;">VOICE</span>';
|
var badge = isModel ? '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--blue);color:white;margin-left:4px;">MODEL</span>' : '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--green);color:white;margin-left:4px;">VOICE</span>';
|
||||||
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
|
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
|
||||||
|
|
@ -1403,7 +1280,7 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
function setTTSDefault(id, type, btn) {
|
function setTTSDefault(id, type, btn) {
|
||||||
var key = type === 'model' ? 'tts.model' : 'tts.voice';
|
var key = type === 'model' ? 'tts.model' : 'tts.voice';
|
||||||
var origText = btn ? btn.textContent : '';
|
var origText = btn ? btn.textContent : '';
|
||||||
adminSetButtonText(btn, '...', true);
|
if (btn) { btn.textContent = '...'; btn.disabled = true; }
|
||||||
|
|
||||||
fetch('/api/admin/config/' + encodeURIComponent(key), {
|
fetch('/api/admin/config/' + encodeURIComponent(key), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|
@ -1412,8 +1289,7 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
adminSetButtonText(btn, 'Set', false);
|
if (btn) { btn.textContent = 'Set'; btn.disabled = false; btn.style.background = 'var(--green)'; setTimeout(function() { btn.style.background = ''; }, 2000); }
|
||||||
adminFlashButtonBackground(btn, 'var(--green)');
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast('TTS ' + type + ' set to: ' + id, 'success');
|
showToast('TTS ' + type + ' set to: ' + id, 'success');
|
||||||
// Update voice selector
|
// Update voice selector
|
||||||
|
|
@ -1433,7 +1309,7 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
adminSetButtonText(btn, origText, false);
|
if (btn) { btn.textContent = origText; btn.disabled = false; }
|
||||||
showToast('Request failed', 'error');
|
showToast('Request failed', 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1444,7 +1320,7 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
var btn = document.getElementById('btn-test-tts');
|
var btn = document.getElementById('btn-test-tts');
|
||||||
var resultEl = document.getElementById('admin-tts-result');
|
var resultEl = document.getElementById('admin-tts-result');
|
||||||
var audioEl = document.getElementById('admin-tts-audio');
|
var audioEl = document.getElementById('admin-tts-audio');
|
||||||
adminSetButtonHtml(btn, '<i class="fas fa-spinner fa-spin"></i> Synthesizing...', true);
|
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Synthesizing...'; }
|
||||||
if (resultEl) resultEl.textContent = '';
|
if (resultEl) resultEl.textContent = '';
|
||||||
if (audioEl) audioEl.style.display = 'none';
|
if (audioEl) audioEl.style.display = 'none';
|
||||||
|
|
||||||
|
|
@ -1455,7 +1331,7 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
adminSetButtonHtml(btn, '<i class="fas fa-play"></i> Synthesize & Play', false);
|
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-play"></i> Synthesize & Play'; }
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
if (resultEl) resultEl.textContent = 'Error: ' + (data.error || 'Unknown error');
|
if (resultEl) resultEl.textContent = 'Error: ' + (data.error || 'Unknown error');
|
||||||
return;
|
return;
|
||||||
|
|
@ -1469,7 +1345,7 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
if (resultEl) resultEl.textContent = 'Provider: ' + (data.provider || '?') + ' · Voice: ' + (data.voice || '?');
|
if (resultEl) resultEl.textContent = 'Provider: ' + (data.provider || '?') + ' · Voice: ' + (data.voice || '?');
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
adminSetButtonHtml(btn, '<i class="fas fa-play"></i> Synthesize & Play', false);
|
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-play"></i> Synthesize & Play'; }
|
||||||
if (resultEl) resultEl.textContent = 'Request failed: ' + err.message;
|
if (resultEl) resultEl.textContent = 'Request failed: ' + err.message;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1481,15 +1357,15 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
return new Blob([bytes], { type: type });
|
return new Blob([bytes], { type: type });
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
})();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// ADMIN STT MANAGEMENT
|
// ADMIN STT MANAGEMENT
|
||||||
// ============================================================
|
// ============================================================
|
||||||
{
|
(function() {
|
||||||
let mediaRecorder = null;
|
var mediaRecorder = null;
|
||||||
let audioChunks = [];
|
var audioChunks = [];
|
||||||
let recording = false;
|
var recording = false;
|
||||||
|
|
||||||
document.addEventListener('tabChanged', function(e) {
|
document.addEventListener('tabChanged', function(e) {
|
||||||
if (e.detail && e.detail.tab === 'admin') loadSTTConfig();
|
if (e.detail && e.detail.tab === 'admin') loadSTTConfig();
|
||||||
|
|
@ -1506,7 +1382,10 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
if (e.target.id === 'admin-stt-search' && e.key === 'Enter') { e.preventDefault(); discoverSTT(); }
|
if (e.target.id === 'admin-stt-search' && e.key === 'Enter') { e.preventDefault(); discoverSTT(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
const esc = adminEscapeHtml;
|
function esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
function loadSTTConfig() {
|
function loadSTTConfig() {
|
||||||
fetch('/api/admin/config/stt', { headers: getAuthHeaders() })
|
fetch('/api/admin/config/stt', { headers: getAuthHeaders() })
|
||||||
|
|
@ -1569,7 +1448,7 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
|
|
||||||
function setSTTDefault(modelId, btn) {
|
function setSTTDefault(modelId, btn) {
|
||||||
var origText = btn ? btn.textContent : '';
|
var origText = btn ? btn.textContent : '';
|
||||||
adminSetButtonText(btn, '...', true);
|
if (btn) { btn.textContent = '...'; btn.disabled = true; }
|
||||||
fetch('/api/admin/config/' + encodeURIComponent('stt.model'), {
|
fetch('/api/admin/config/' + encodeURIComponent('stt.model'), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
|
|
@ -1577,13 +1456,12 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
adminSetButtonText(btn, 'Set', false);
|
if (btn) { btn.textContent = 'Set'; btn.disabled = false; btn.style.background = data.success ? 'var(--green)' : ''; setTimeout(function() { if (btn) btn.style.background = ''; }, 2000); }
|
||||||
adminFlashButtonBackground(btn, data.success ? 'var(--green)' : '');
|
|
||||||
if (data.success) { showToast('STT model set to: ' + modelId, 'success'); loadSTTConfig(); }
|
if (data.success) { showToast('STT model set to: ' + modelId, 'success'); loadSTTConfig(); }
|
||||||
else showToast(data.error || 'Failed', 'error');
|
else showToast(data.error || 'Failed', 'error');
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
adminSetButtonText(btn, origText, false);
|
if (btn) { btn.textContent = origText; btn.disabled = false; }
|
||||||
showToast('Request failed', 'error');
|
showToast('Request failed', 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1669,12 +1547,12 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
reader.readAsDataURL(blob);
|
reader.readAsDataURL(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
})();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// ADMIN EMBEDDING MODELS MANAGEMENT
|
// ADMIN EMBEDDING MODELS MANAGEMENT
|
||||||
// ============================================================
|
// ============================================================
|
||||||
{
|
(function() {
|
||||||
document.addEventListener('tabChanged', function(e) {
|
document.addEventListener('tabChanged', function(e) {
|
||||||
if (e.detail && e.detail.tab === 'admin') loadEmbeddingConfig();
|
if (e.detail && e.detail.tab === 'admin') loadEmbeddingConfig();
|
||||||
});
|
});
|
||||||
|
|
@ -1690,7 +1568,10 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
if (e.target.id === 'admin-embed-search' && e.key === 'Enter') { e.preventDefault(); discoverEmbeddings(); }
|
if (e.target.id === 'admin-embed-search' && e.key === 'Enter') { e.preventDefault(); discoverEmbeddings(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
const esc = adminEscapeHtml;
|
function esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
function loadEmbeddingConfig() {
|
function loadEmbeddingConfig() {
|
||||||
fetch('/api/admin/config/embeddings', { headers: getAuthHeaders() })
|
fetch('/api/admin/config/embeddings', { headers: getAuthHeaders() })
|
||||||
|
|
@ -1764,7 +1645,7 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
|
|
||||||
function setEmbeddingDefault(modelId, dims, btn) {
|
function setEmbeddingDefault(modelId, dims, btn) {
|
||||||
var origText = btn ? btn.textContent : '';
|
var origText = btn ? btn.textContent : '';
|
||||||
adminSetButtonText(btn, '...', true);
|
if (btn) { btn.textContent = '...'; btn.disabled = true; }
|
||||||
var promises = [
|
var promises = [
|
||||||
fetch('/api/admin/config/' + encodeURIComponent('embeddings.model'), {
|
fetch('/api/admin/config/' + encodeURIComponent('embeddings.model'), {
|
||||||
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: modelId })
|
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: modelId })
|
||||||
|
|
@ -1780,13 +1661,12 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
Promise.all(promises)
|
Promise.all(promises)
|
||||||
.then(function(results) {
|
.then(function(results) {
|
||||||
var ok = results.every(function(r) { return r.success; });
|
var ok = results.every(function(r) { return r.success; });
|
||||||
adminSetButtonText(btn, 'Set', false);
|
if (btn) { btn.textContent = 'Set'; btn.disabled = false; btn.style.background = ok ? 'var(--green)' : ''; setTimeout(function() { if (btn) btn.style.background = ''; }, 2000); }
|
||||||
adminFlashButtonBackground(btn, ok ? 'var(--green)' : '');
|
|
||||||
if (ok) { showToast('Embedding model set to: ' + modelId + (dims ? ' (' + dims + 'd)' : ''), 'success'); loadEmbeddingConfig(); }
|
if (ok) { showToast('Embedding model set to: ' + modelId + (dims ? ' (' + dims + 'd)' : ''), 'success'); loadEmbeddingConfig(); }
|
||||||
else showToast(results[0].error || 'Failed', 'error');
|
else showToast(results[0].error || 'Failed', 'error');
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
adminSetButtonText(btn, origText, false);
|
if (btn) { btn.textContent = origText; btn.disabled = false; }
|
||||||
showToast('Request failed', 'error');
|
showToast('Request failed', 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1795,7 +1675,7 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
var text = (document.getElementById('admin-embed-test-text') || {}).value || 'test';
|
var text = (document.getElementById('admin-embed-test-text') || {}).value || 'test';
|
||||||
var resultEl = document.getElementById('admin-embed-result');
|
var resultEl = document.getElementById('admin-embed-result');
|
||||||
var btn = document.getElementById('btn-test-embedding');
|
var btn = document.getElementById('btn-test-embedding');
|
||||||
adminSetButtonHtml(btn, '<i class="fas fa-spinner fa-spin"></i>', true);
|
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; }
|
||||||
if (resultEl) resultEl.textContent = 'Generating...';
|
if (resultEl) resultEl.textContent = 'Generating...';
|
||||||
|
|
||||||
fetch('/api/admin/config/embeddings/test', {
|
fetch('/api/admin/config/embeddings/test', {
|
||||||
|
|
@ -1805,7 +1685,7 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
adminSetButtonHtml(btn, '<i class="fas fa-code-branch"></i> Generate', false);
|
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-code-branch"></i> Generate'; }
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
if (resultEl) resultEl.innerHTML = '<span style="color:var(--red);">Error: ' + esc(data.error || 'Failed') + '</span>';
|
if (resultEl) resultEl.innerHTML = '<span style="color:var(--red);">Error: ' + esc(data.error || 'Failed') + '</span>';
|
||||||
return;
|
return;
|
||||||
|
|
@ -1819,9 +1699,9 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
adminSetButtonHtml(btn, '<i class="fas fa-code-branch"></i> Generate', false);
|
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-code-branch"></i> Generate'; }
|
||||||
if (resultEl) resultEl.textContent = 'Request failed: ' + err.message;
|
if (resultEl) resultEl.textContent = 'Request failed: ' + err.message;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
})();
|
||||||
|
|
|
||||||
336
public/js/app.js
336
public/js/app.js
|
|
@ -3,38 +3,30 @@
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
// ── Client-side error logging ──────────────────────────────
|
// ── Client-side error logging ──────────────────────────────
|
||||||
function sendError(data) {
|
(function() {
|
||||||
try {
|
function sendError(data) {
|
||||||
navigator.sendBeacon('/api/logs/client-error', new Blob(
|
try {
|
||||||
[JSON.stringify(data)], { type: 'application/json' }
|
navigator.sendBeacon('/api/logs/client-error', new Blob(
|
||||||
));
|
[JSON.stringify(data)], { type: 'application/json' }
|
||||||
} catch(e) {}
|
));
|
||||||
}
|
} catch(e) {}
|
||||||
|
}
|
||||||
window.onerror = function(msg, src, line, col, err) {
|
window.onerror = function(msg, src, line, col, err) {
|
||||||
// Ignore errors from browser extensions
|
// Ignore errors from browser extensions
|
||||||
if (src && src.indexOf('moz-extension') !== -1) return;
|
if (src && src.indexOf('moz-extension') !== -1) return;
|
||||||
if (src && src.indexOf('chrome-extension') !== -1) return;
|
if (src && src.indexOf('chrome-extension') !== -1) return;
|
||||||
sendError({ type: 'uncaught', message: String(msg), source: src, line: line, col: col, stack: err && err.stack });
|
sendError({ type: 'uncaught', message: String(msg), source: src, line: line, col: col, stack: err && err.stack });
|
||||||
};
|
};
|
||||||
|
window.addEventListener('unhandledrejection', function(e) {
|
||||||
window.addEventListener('unhandledrejection', function(e) {
|
var msg = e.reason ? (e.reason.message || String(e.reason)) : 'Unhandled promise rejection';
|
||||||
var msg = e.reason ? (e.reason.message || String(e.reason)) : 'Unhandled promise rejection';
|
sendError({ type: 'unhandledrejection', message: msg, stack: e.reason && e.reason.stack });
|
||||||
sendError({ type: 'unhandledrejection', message: msg, stack: e.reason && e.reason.stack });
|
});
|
||||||
});
|
})();
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
||||||
// --- COMPONENT LOADER (lazy-load tab HTML from /components/) ---
|
// --- COMPONENT LOADER (lazy-load tab HTML from /components/) ---
|
||||||
function getComponentVersion() {
|
var COMPONENT_VERSION = '7.1.3';
|
||||||
try {
|
|
||||||
var script = document.currentScript || document.querySelector('script[src^="/js/app.js"]');
|
|
||||||
var version = script ? new URL(script.src, window.location.href).searchParams.get('v') : '';
|
|
||||||
return version || 'dev';
|
|
||||||
} catch(e) { return 'dev'; }
|
|
||||||
}
|
|
||||||
var COMPONENT_VERSION = getComponentVersion();
|
|
||||||
window.PEDSCRIBE_COMPONENT_VERSION = COMPONENT_VERSION;
|
|
||||||
var _componentCache = {};
|
var _componentCache = {};
|
||||||
var _componentLoading = {};
|
var _componentLoading = {};
|
||||||
|
|
||||||
|
|
@ -171,6 +163,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList();
|
if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList();
|
||||||
if (typeof renderAudioBackups === 'function') renderAudioBackups();
|
if (typeof renderAudioBackups === 'function') renderAudioBackups();
|
||||||
if (typeof loadDocuments === 'function') loadDocuments();
|
if (typeof loadDocuments === 'function') loadDocuments();
|
||||||
|
initBrowserWhisperSettings();
|
||||||
}
|
}
|
||||||
if (e.detail && e.detail.tab === 'faq') {
|
if (e.detail && e.detail.tab === 'faq') {
|
||||||
// Wire FAQ accordion after component loads
|
// Wire FAQ accordion after component loads
|
||||||
|
|
@ -190,10 +183,110 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- MODEL SELECTORS ---
|
// ── Browser Whisper settings UI ───────────────────────────
|
||||||
|
function initBrowserWhisperSettings() {
|
||||||
|
var chk = document.getElementById('browser-whisper-enabled');
|
||||||
|
var sel = document.getElementById('browser-whisper-model');
|
||||||
|
var pre = document.getElementById('btn-whisper-preload');
|
||||||
|
var stat = document.getElementById('browser-whisper-status');
|
||||||
|
var prog = document.getElementById('browser-whisper-progress');
|
||||||
|
var pt = document.getElementById('browser-whisper-progress-text');
|
||||||
|
var sec = document.getElementById('browser-whisper-section');
|
||||||
|
if (!chk) return;
|
||||||
|
|
||||||
|
var supported = typeof BrowserWhisper !== 'undefined' && BrowserWhisper.isSupported();
|
||||||
|
if (!supported) {
|
||||||
|
if (sec) sec.innerHTML += '<p style="color:var(--red);font-size:12px;margin:8px 0 0;">Not supported in this browser. Use Chrome or Edge.</p>';
|
||||||
|
if (chk) chk.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore saved state
|
||||||
|
chk.checked = BrowserWhisper.isEnabled();
|
||||||
|
sel.value = BrowserWhisper.getModel();
|
||||||
|
stat.textContent = chk.checked ? 'On — audio stays on device' : 'Off';
|
||||||
|
|
||||||
|
chk.addEventListener('change', function() {
|
||||||
|
BrowserWhisper.setEnabled(chk.checked);
|
||||||
|
stat.textContent = chk.checked ? 'On — audio stays on device' : 'Off';
|
||||||
|
if (chk.checked) {
|
||||||
|
BrowserWhisper.preload(function(file, pct) {
|
||||||
|
if (!prog || !pt) return;
|
||||||
|
if (pct >= 100) { prog.style.display = 'none'; return; }
|
||||||
|
prog.style.display = 'block';
|
||||||
|
pt.textContent = file + (pct > 0 ? ' ' + pct + '%' : '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sel.addEventListener('change', function() {
|
||||||
|
BrowserWhisper.setModel(sel.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pre) {
|
||||||
|
pre.addEventListener('click', function(e) {
|
||||||
|
console.log('[BrowserWhisper] Pre-download button clicked!');
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!prog || !pt) {
|
||||||
|
console.error('[BrowserWhisper] Progress elements not found');
|
||||||
|
showToast('UI elements missing - check page load', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!BrowserWhisper || !BrowserWhisper.isSupported()) {
|
||||||
|
console.error('[BrowserWhisper] Not supported');
|
||||||
|
showToast('Browser Whisper not supported in this browser', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[BrowserWhisper] Starting preload...');
|
||||||
|
prog.style.display = 'block';
|
||||||
|
pt.textContent = 'Initializing...';
|
||||||
|
|
||||||
|
BrowserWhisper.setEnabled(true);
|
||||||
|
chk.checked = true;
|
||||||
|
stat.textContent = 'On — audio stays on device';
|
||||||
|
|
||||||
|
// Set timeout in case it gets stuck
|
||||||
|
var timeout = setTimeout(function() {
|
||||||
|
console.warn('[BrowserWhisper] 30s elapsed - still downloading, check Network tab');
|
||||||
|
showToast('Download in progress - check browser console', 'info');
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
BrowserWhisper.preload(function(file, pct) {
|
||||||
|
console.log('[BrowserWhisper] Progress:', file, pct + '%');
|
||||||
|
if (pct >= 100) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
prog.style.display = 'none';
|
||||||
|
showToast('Whisper model ready!', 'success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prog.style.display = 'block';
|
||||||
|
pt.textContent = file + (pct > 0 ? ' ' + pct + '%' : '');
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.error('[BrowserWhisper] Preload error:', err);
|
||||||
|
prog.style.display = 'none';
|
||||||
|
pt.textContent = '';
|
||||||
|
|
||||||
|
// Show CSP/network warning
|
||||||
|
var cspWarning = document.getElementById('browser-whisper-csp-warning');
|
||||||
|
if (cspWarning) cspWarning.style.display = 'block';
|
||||||
|
|
||||||
|
showToast('Download blocked by network/firewall. Server transcription will be used.', 'warning');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MODEL SELECTOR ---
|
||||||
|
var modelSelect = document.getElementById('global-model-select');
|
||||||
|
var costBadge = document.getElementById('model-cost-badge');
|
||||||
window._currentModels = [];
|
window._currentModels = [];
|
||||||
window._currentProvider = 'openrouter';
|
window._currentProvider = 'openrouter';
|
||||||
window._defaultModelId = '';
|
|
||||||
|
|
||||||
fetch('/api/models')
|
fetch('/api/models')
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
|
|
@ -209,26 +302,33 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
opt.textContent = m.name;
|
opt.textContent = m.name;
|
||||||
selectEl.appendChild(opt);
|
selectEl.appendChild(opt);
|
||||||
});
|
});
|
||||||
if (window._defaultModelId && !Array.prototype.some.call(selectEl.options, function(opt) { return opt.value === window._defaultModelId; })) {
|
|
||||||
var saved = document.createElement('option');
|
|
||||||
saved.value = window._defaultModelId;
|
|
||||||
saved.textContent = window._defaultModelId + ' (saved default)';
|
|
||||||
selectEl.appendChild(saved);
|
|
||||||
}
|
|
||||||
if (window._defaultModelId) selectEl.value = window._defaultModelId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine default model (admin override or first model)
|
// Determine default model (admin override or first model)
|
||||||
var defaultModelId = data.defaultModel || (window._currentModels.length > 0 ? window._currentModels[0].id : '');
|
var defaultModelId = data.defaultModel || (window._currentModels.length > 0 ? window._currentModels[0].id : '');
|
||||||
window._defaultModelId = defaultModelId;
|
|
||||||
|
if (modelSelect && window._currentModels.length > 0) {
|
||||||
|
window._buildModelOptions(modelSelect);
|
||||||
|
// Select the admin-configured default
|
||||||
|
if (defaultModelId) modelSelect.value = defaultModelId;
|
||||||
|
if (costBadge) costBadge.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Populate all per-tab model selectors already in DOM
|
// Populate all per-tab model selectors already in DOM
|
||||||
document.querySelectorAll('.tab-model-select').forEach(function(sel) {
|
document.querySelectorAll('.tab-model-select').forEach(function(sel) {
|
||||||
window._buildModelOptions(sel);
|
window._buildModelOptions(sel);
|
||||||
|
if (defaultModelId) sel.value = defaultModelId;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(function(err) { console.warn('Models load failed:', err); });
|
.catch(function(err) { console.warn('Models load failed:', err); });
|
||||||
|
|
||||||
|
if (modelSelect) {
|
||||||
|
modelSelect.addEventListener('change', function() {
|
||||||
|
var m = window._currentModels.find(function(x) { return x.id === modelSelect.value; });
|
||||||
|
showToast('Model: ' + modelSelect.value.split('/').pop(), 'info');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log('✅ App.js DOM ready');
|
console.log('✅ App.js DOM ready');
|
||||||
|
|
||||||
}); // end DOMContentLoaded
|
}); // end DOMContentLoaded
|
||||||
|
|
@ -310,13 +410,15 @@ function loadAnnouncement() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close button — dismiss for this page view only (reappears on refresh/re-login)
|
// Close button — dismiss for this page view only (reappears on refresh/re-login)
|
||||||
var closeBtn = document.getElementById('announcement-close');
|
(function() {
|
||||||
if (closeBtn) {
|
var closeBtn = document.getElementById('announcement-close');
|
||||||
closeBtn.addEventListener('click', function() {
|
if (closeBtn) {
|
||||||
var banner = document.getElementById('announcement-banner');
|
closeBtn.addEventListener('click', function() {
|
||||||
if (banner) banner.classList.add('hidden');
|
var banner = document.getElementById('announcement-banner');
|
||||||
});
|
if (banner) banner.classList.add('hidden');
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
function getSelectedModel() {
|
function getSelectedModel() {
|
||||||
// Prefer the active tab's own model selector if present
|
// Prefer the active tab's own model selector if present
|
||||||
|
|
@ -325,7 +427,8 @@ function getSelectedModel() {
|
||||||
var tabSel = activeTab.querySelector('.tab-model-select');
|
var tabSel = activeTab.querySelector('.tab-model-select');
|
||||||
if (tabSel && tabSel.value) return tabSel.value;
|
if (tabSel && tabSel.value) return tabSel.value;
|
||||||
}
|
}
|
||||||
return undefined;
|
var sel = document.getElementById('global-model-select');
|
||||||
|
return sel ? sel.value : 'google/gemini-2.5-flash';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLoading(text) {
|
function showLoading(text) {
|
||||||
|
|
@ -600,19 +703,9 @@ window.nativeStopRecordingService = function() {
|
||||||
try { if (window.NativeRecording) window.NativeRecording.stopForegroundService(); } catch(e) {}
|
try { if (window.NativeRecording) window.NativeRecording.stopForegroundService(); } catch(e) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep screen awake during recording.
|
// Keep screen awake during recording (Capacitor KeepAwake or InsomniaCap)
|
||||||
//
|
|
||||||
// Prefer the NativeRecording bridge (addJavascriptInterface, so it is present
|
|
||||||
// on the remote origin the launcher navigates to). The Capacitor KeepAwake
|
|
||||||
// plugin is kept as a fallback but is NOT installed in this project — relying
|
|
||||||
// on it alone meant this function silently did nothing and the screen slept
|
|
||||||
// mid-recording, killing the MediaRecorder.
|
|
||||||
window.nativeKeepAwake = function(on) {
|
window.nativeKeepAwake = function(on) {
|
||||||
try {
|
try {
|
||||||
if (window.NativeRecording && typeof window.NativeRecording.keepAwake === 'function') {
|
|
||||||
window.NativeRecording.keepAwake(!!on);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake) {
|
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake) {
|
||||||
if (on) window.Capacitor.Plugins.KeepAwake.keepAwake();
|
if (on) window.Capacitor.Plugins.KeepAwake.keepAwake();
|
||||||
else window.Capacitor.Plugins.KeepAwake.allowSleep();
|
else window.Capacitor.Plugins.KeepAwake.allowSleep();
|
||||||
|
|
@ -645,6 +738,22 @@ function checkTranscribeStatus() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function transcribeAudio(blob) {
|
function transcribeAudio(blob) {
|
||||||
|
// Browser Whisper — local, zero network, HIPAA-safe
|
||||||
|
if (typeof BrowserWhisper !== 'undefined' && BrowserWhisper.isEnabled()) {
|
||||||
|
var startTime = Date.now();
|
||||||
|
return BrowserWhisper.transcribe(blob)
|
||||||
|
.then(function(text) {
|
||||||
|
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
showToast('Transcribed locally (' + elapsed + 's)', 'success');
|
||||||
|
window._lastAudioBackupId = null;
|
||||||
|
return { success: true, text: text, provider: 'browser-whisper' };
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
console.warn('[BrowserWhisper] Failed:', err.message, '— falling back to server');
|
||||||
|
if (typeof saveAudioBackup === 'function') saveAudioBackup(blob, 'browser-whisper-failed').catch(function() {});
|
||||||
|
return _serverTranscribe(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
return _serverTranscribe(blob);
|
return _serverTranscribe(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -753,8 +862,8 @@ function suggestBillingCodes(outputElementId, noteText, noteType, patientAge, vi
|
||||||
|
|
||||||
if (data.emLevel) {
|
if (data.emLevel) {
|
||||||
html += '<div class="billing-codes-section"><div class="billing-codes-label">E/M Assessment</div>';
|
html += '<div class="billing-codes-section"><div class="billing-codes-label">E/M Assessment</div>';
|
||||||
html += '<span class="billing-code-chip em">Level ' + escHtml(data.emLevel.level) + '</span>';
|
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: ' + escHtml(data.emLevel.complexity) + ' | ' + escHtml(data.emLevel.diagnosisCount) + ' dx | ' + escHtml(data.emLevel.rosCount) + ' ROS | ' + escHtml(data.emLevel.peCount) + ' PE</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 += '</div>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -835,112 +944,6 @@ function suggestDontMiss(outputElementId, noteText, noteType, patientAge, chiefC
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Patient education handout helper ────────────────────────
|
|
||||||
// Adds a reusable "Handout" action beside generated clinical notes. The actual
|
|
||||||
// handout is generated only when the physician clicks Generate in the panel.
|
|
||||||
function attachPatientEducation(outputElementId, opts) {
|
|
||||||
var outputEl = document.getElementById(outputElementId);
|
|
||||||
if (!outputEl) return;
|
|
||||||
opts = opts || {};
|
|
||||||
var card = outputEl.closest('.card, .output-card');
|
|
||||||
if (!card) return;
|
|
||||||
var actions = card.querySelector('.output-actions');
|
|
||||||
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
||||||
|
|
||||||
if (actions && !document.getElementById(prefix + '-patient-ed-btn')) {
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.id = prefix + '-patient-ed-btn';
|
|
||||||
btn.className = 'btn-sm btn-ghost';
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.innerHTML = '<i class="fas fa-person-breastfeeding"></i> Handout';
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
var panel = ensurePatientEducationPanel(outputElementId, opts);
|
|
||||||
panel.classList.remove('hidden');
|
|
||||||
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
});
|
|
||||||
actions.appendChild(btn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensurePatientEducationPanel(outputElementId, opts) {
|
|
||||||
var outputEl = document.getElementById(outputElementId);
|
|
||||||
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
||||||
var panelId = prefix + '-patient-ed';
|
|
||||||
var panel = document.getElementById(panelId);
|
|
||||||
if (panel) return panel;
|
|
||||||
|
|
||||||
panel = document.createElement('div');
|
|
||||||
panel.id = panelId;
|
|
||||||
panel.className = 'card patient-ed-card hidden';
|
|
||||||
panel.style.cssText = 'margin-top:10px;border-left:3px solid #0ea5e9;';
|
|
||||||
panel.innerHTML =
|
|
||||||
'<div class="card-header output-header">' +
|
|
||||||
'<h3><i class="fas fa-person-breastfeeding" style="color:#0ea5e9;"></i> Patient Education Handout</h3>' +
|
|
||||||
'<div class="output-actions">' +
|
|
||||||
'<button class="btn-sm btn-primary" id="' + prefix + '-patient-ed-generate" type="button"><i class="fas fa-wand-magic-sparkles"></i> Generate</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div style="padding:10px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;align-items:end;">' +
|
|
||||||
'<div class="demo-field"><label>Parent language</label><select id="' + prefix + '-patient-ed-language">' +
|
|
||||||
'<option>English</option><option>Spanish</option><option>French</option><option>Arabic</option><option>Haitian Creole</option><option>Chinese</option><option>Russian</option><option>Portuguese</option>' +
|
|
||||||
'</select></div>' +
|
|
||||||
'<div class="demo-field"><label>Diagnosis/context</label><input type="text" id="' + prefix + '-patient-ed-diagnosis" placeholder="Optional: diagnosis to emphasize"></div>' +
|
|
||||||
'<div class="demo-field"><label>Medications</label><input type="text" id="' + prefix + '-patient-ed-meds" placeholder="Optional: meds/doses from plan"></div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div id="' + prefix + '-patient-ed-text" class="output-text" contenteditable="true" style="margin:0 16px 12px;min-height:120px;" data-placeholder="Generated parent handout appears here..."></div>' +
|
|
||||||
'<div style="padding:0 16px 12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
|
|
||||||
'<button class="btn-sm btn-primary" data-action="copy" data-target="' + prefix + '-patient-ed-text"><i class="fas fa-copy"></i> Copy</button>' +
|
|
||||||
'<span style="font-size:11px;color:var(--g500);">Parent-facing draft. Verify before sharing.</span>' +
|
|
||||||
'</div>';
|
|
||||||
|
|
||||||
outputEl.parentNode.insertBefore(panel, outputEl.nextSibling);
|
|
||||||
var gen = panel.querySelector('#' + prefix + '-patient-ed-generate');
|
|
||||||
if (gen) gen.addEventListener('click', function() { generatePatientEducation(outputElementId, opts); });
|
|
||||||
return panel;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generatePatientEducation(outputElementId, opts) {
|
|
||||||
var outputEl = document.getElementById(outputElementId);
|
|
||||||
if (!outputEl) return;
|
|
||||||
opts = opts || {};
|
|
||||||
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
||||||
var handoutEl = document.getElementById(prefix + '-patient-ed-text');
|
|
||||||
var langEl = document.getElementById(prefix + '-patient-ed-language');
|
|
||||||
var dxEl = document.getElementById(prefix + '-patient-ed-diagnosis');
|
|
||||||
var medsEl = document.getElementById(prefix + '-patient-ed-meds');
|
|
||||||
var noteText = (outputEl.innerText || outputEl.textContent || '').trim();
|
|
||||||
if (!noteText) { showToast('No note for handout', 'error'); return; }
|
|
||||||
|
|
||||||
if (handoutEl) handoutEl.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating parent handout...';
|
|
||||||
fetch('/api/patient-education', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
body: JSON.stringify({
|
|
||||||
noteText: noteText,
|
|
||||||
diagnosis: dxEl ? dxEl.value : '',
|
|
||||||
medications: medsEl ? medsEl.value : '',
|
|
||||||
patientAge: opts.patientAge || '',
|
|
||||||
language: langEl ? langEl.value : 'English',
|
|
||||||
readingLevel: '6th grade plain language',
|
|
||||||
model: getSelectedModel()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
|
||||||
if (!data.success) {
|
|
||||||
if (handoutEl) handoutEl.textContent = '';
|
|
||||||
showToast(data.error || 'Handout generation failed', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOutputText(handoutEl, data.handout || '');
|
|
||||||
showToast('Patient handout generated', 'success');
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
if (handoutEl) handoutEl.textContent = '';
|
|
||||||
showToast(err.message || 'Handout generation failed', 'error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function refineDocument(outputElementId, inputElementId) {
|
function refineDocument(outputElementId, inputElementId) {
|
||||||
var doc = document.getElementById(outputElementId);
|
var doc = document.getElementById(outputElementId);
|
||||||
var input = document.getElementById(inputElementId);
|
var input = document.getElementById(inputElementId);
|
||||||
|
|
@ -1003,7 +1006,6 @@ function exportToNextcloud(elementId, docType) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSpeechRecognition() {
|
function createSpeechRecognition() {
|
||||||
if (window.WebSpeechRecognition && !window.WebSpeechRecognition.isEnabled()) return null;
|
|
||||||
if (!(window.SpeechRecognition || window.webkitSpeechRecognition)) return null;
|
if (!(window.SpeechRecognition || window.webkitSpeechRecognition)) return null;
|
||||||
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
var rec = new SR();
|
var rec = new SR();
|
||||||
|
|
@ -1038,7 +1040,7 @@ function deduplicateFinal(newText, existingText) {
|
||||||
|
|
||||||
// PWA Service Worker
|
// PWA Service Worker
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.register('/sw.js?v=' + encodeURIComponent(window.PEDSCRIBE_COMPONENT_VERSION || 'dev')).catch(function() {});
|
navigator.serviceWorker.register('/sw.js').catch(function() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('✅ App.js loaded');
|
console.log('✅ App.js loaded');
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
function authHeaders() {
|
|
||||||
return window.getAuthHeaders ? window.getAuthHeaders() : { 'Content-Type': 'application/json' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonWithStatus(response) {
|
|
||||||
return response.json().catch(function () { return {}; }).then(function(data) {
|
|
||||||
data._status = response.status;
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchAssistantStatus() {
|
|
||||||
return fetch('/api/clinical-assistant/status', { headers: authHeaders(), credentials: 'same-origin' })
|
|
||||||
.then(function(r) { return r.json(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchAssistantExamples() {
|
|
||||||
return fetch('/api/clinical-assistant/examples', { headers: authHeaders(), credentials: 'same-origin' })
|
|
||||||
.then(function(r) { return r.json(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function openAssistantStream(payload, options) {
|
|
||||||
options = options || {};
|
|
||||||
return fetch('/api/clinical-assistant/chat/stream', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: authHeaders(),
|
|
||||||
credentials: 'same-origin',
|
|
||||||
signal: options.signal,
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchAssistantChat(payload, options) {
|
|
||||||
options = options || {};
|
|
||||||
return fetch('/api/clinical-assistant/chat', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: authHeaders(),
|
|
||||||
credentials: 'same-origin',
|
|
||||||
signal: options.signal,
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
}).then(parseJsonWithStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requestAssistantImage(prompt) {
|
|
||||||
return fetch('/api/clinical-assistant/image', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: authHeaders(),
|
|
||||||
credentials: 'same-origin',
|
|
||||||
body: JSON.stringify({ prompt: prompt })
|
|
||||||
}).then(function(r) { return r.json(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startAssistantImageJob(prompt) {
|
|
||||||
return fetch('/api/clinical-assistant/image/jobs', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: authHeaders(),
|
|
||||||
credentials: 'same-origin',
|
|
||||||
body: JSON.stringify({ prompt: prompt })
|
|
||||||
}).then(function(r) { return r.json(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchAssistantImageJob(jobId) {
|
|
||||||
return fetch('/api/clinical-assistant/image/jobs/' + encodeURIComponent(jobId), {
|
|
||||||
headers: authHeaders(),
|
|
||||||
credentials: 'same-origin'
|
|
||||||
}).then(function(r) { return r.json(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveAssistantChat(payload) {
|
|
||||||
return fetch('/api/clinical-assistant/chats', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: authHeaders(),
|
|
||||||
credentials: 'same-origin',
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
}).then(parseJsonWithStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchSavedAssistantChats() {
|
|
||||||
return fetch('/api/clinical-assistant/chats', { headers: authHeaders(), credentials: 'same-origin' })
|
|
||||||
.then(function(r) { return r.json(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchSavedAssistantChat(id) {
|
|
||||||
return fetch('/api/clinical-assistant/chats/' + encodeURIComponent(id), { headers: authHeaders(), credentials: 'same-origin' })
|
|
||||||
.then(parseJsonWithStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteSavedAssistantChat(id) {
|
|
||||||
return fetch('/api/clinical-assistant/chats/' + encodeURIComponent(id), {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: authHeaders(),
|
|
||||||
credentials: 'same-origin'
|
|
||||||
}).then(function(r) { return r.json(); });
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +1,43 @@
|
||||||
export function renderAssistantMarkdown(md, sources, options) {
|
export function renderAssistantMarkdown(md, sources, options) {
|
||||||
var opts = options || {};
|
var opts = options || {};
|
||||||
|
var text = normalizeMarkdownText(md);
|
||||||
var codeBlocks = [];
|
var codeBlocks = [];
|
||||||
var text = String(md || '').replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) {
|
text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) {
|
||||||
var idx = codeBlocks.length;
|
var idx = codeBlocks.length;
|
||||||
codeBlocks.push({ lang: (lang || '').toLowerCase(), code: code });
|
codeBlocks.push({ lang: (lang || '').toLowerCase(), code: code });
|
||||||
return '\n@@CODEBLOCK_' + idx + '@@\n';
|
return '\n@@CODEBLOCK_' + idx + '@@\n';
|
||||||
});
|
});
|
||||||
text = stripOrphanMarkdownMarkers(normalizeMarkdownText(text));
|
text = stripSourcesSection(text);
|
||||||
text = renderLatexText(text, opts.katex);
|
text = renderLatexText(text, opts.katex);
|
||||||
text = normalizeAdjacentCitationClusters(text, sources || []);
|
text = normalizeAdjacentCitationClusters(text, sources || []);
|
||||||
|
|
||||||
var html;
|
var html;
|
||||||
if (opts.marked && typeof opts.marked.parse === 'function') {
|
if (opts.marked && typeof opts.marked.parse === 'function') {
|
||||||
html = opts.marked.parse(text, { breaks: true, gfm: true });
|
html = opts.marked.parse(text, { breaks: true, gfm: true });
|
||||||
} else if (opts.markdownIt && typeof opts.markdownIt.render === 'function') {
|
|
||||||
html = opts.markdownIt.render(text);
|
|
||||||
} else {
|
} else {
|
||||||
html = fallbackMarkdown(text);
|
html = fallbackMarkdown(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
html = renderCitationLinks(html, sources || [], opts);
|
html = renderCitationLinks(html, sources || []);
|
||||||
html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) {
|
html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) {
|
||||||
var block = codeBlocks[Number(idx)] || { lang: '', code: '' };
|
var block = codeBlocks[Number(idx)] || { lang: '', code: '' };
|
||||||
if (block.lang === 'mermaid') return '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(block.code) + '">Rendering graph...</div>';
|
if (block.lang === 'mermaid') return '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(block.code) + '">Rendering graph...</div>';
|
||||||
if (block.lang === 'chart' || block.lang === 'chartjs') return '<canvas class="assistant-chart" data-chart="' + escapeAttr(block.code) + '"></canvas>';
|
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>';
|
return '<pre><code>' + escapeHtml(block.code) + '</code></pre>';
|
||||||
});
|
});
|
||||||
html = wrapTables(html);
|
|
||||||
|
|
||||||
return typeof opts.sanitize === 'function' ? opts.sanitize(html) : html;
|
return typeof opts.sanitize === 'function' ? opts.sanitize(html) : html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrapTables(html) {
|
export function renderCitationLinks(html, sources) {
|
||||||
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) {
|
return String(html || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function (match, cluster) {
|
||||||
var nums = cluster.split(',').map(function (n) { return Number(n.trim()); }).filter(function (n) { return Number.isInteger(n) && n > 0; });
|
var nums = cluster.split(',').map(function (n) { return Number(n.trim()); }).filter(function (n) { return Number.isInteger(n) && n > 0; });
|
||||||
if (!nums.length || nums.some(function (n) { return !sources[n - 1]; })) return match;
|
if (!nums.length || nums.some(function (n) { return !sources[n - 1]; })) return match;
|
||||||
return nums.map(function (n) {
|
return '[' + nums.map(function (n) {
|
||||||
var source = sources[n - 1];
|
var source = sources[n - 1];
|
||||||
var title = source ? source.title || source.resource || 'Source' : 'Source';
|
var title = source ? source.title || source.resource || 'Source' : 'Source';
|
||||||
var page = source && (source.page || source.page_number || source.pageNumber);
|
return '<a class="assistant-cite" href="#assistant-source-' + n + '" title="' + escapeHtml(title) + '">' + n + '</a>';
|
||||||
var label = 'Source ' + n + ': ' + title + (page ? ', page ' + page : '');
|
}).join(', ') + ']';
|
||||||
var text = opts.citationLabel === 'number' ? String(n) : 'src';
|
|
||||||
return '<a class="assistant-cite" href="#assistant-source-' + n + '" data-source-number="' + n + '" title="' + escapeHtml(label) + '" aria-label="' + escapeAttr(label) + '">' + text + '</a>';
|
|
||||||
}).join(' ');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,69 +78,25 @@ function formatCitationCluster(nums) {
|
||||||
return '[' + nums.join(', ') + ']';
|
return '[' + nums.join(', ') + ']';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stripSourcesSection(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/\s*(?:-{3,}\s*)?(?:#{1,6}\s*)?(?:Sources|References)\s*:?\s*[\s\S]*$/i, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeMarkdownText(text) {
|
export function normalizeMarkdownText(text) {
|
||||||
return stripOrphanMarkdownMarkers(normalizeTableSourceCitationCells(String(text || '')
|
return String(text || '')
|
||||||
.replace(/\r\n/g, '\n')
|
.replace(/\r\n/g, '\n')
|
||||||
.replace(/(\[(?:\d+\s*,\s*)*\d+\])\s*[-–—]\s*/g, '$1\n- ')
|
.replace(/(\[(?:\d+\s*,\s*)*\d+\])\s*-\s+/g, '$1\n- ')
|
||||||
.replace(/([.!?])\s*[-–—]\s+(\*\*)?/g, '$1\n- $2')
|
.replace(/([.!?])\s*-\s+(\*\*)?/g, '$1\n- $2')
|
||||||
.replace(/(:)\s*[-–—]\s+(\*\*)?/g, '$1\n- $2')
|
|
||||||
.replace(/(\[(?:\d+\s*,\s*)*\d+\]\.)\s+(\d+\.\s+[A-Z][A-Za-z][^\n]{0,80})/g, '$1\n$2')
|
|
||||||
.replace(/([.!?])\s+(\d+\.\s+[A-Z][A-Za-z][^\n]{0,80})/g, '$1\n$2')
|
|
||||||
.replace(/(\[(?:\d+\s*,\s*)*\d+\])(?=\s*(?:[A-Z][A-Za-z]+\s+){1,4}(?:deficits?|distress|apnoea|apnea|vomiting|seizures?|signs?|symptoms?|criteria|indications?|risk|oxygen|saturation|dehydration|lethargy|toxicity)\b)/g, '$1\n')
|
|
||||||
.replace(/([^\n])\s+(#{1,4}\s+)/g, '$1\n\n$2')
|
.replace(/([^\n])\s+(#{1,4}\s+)/g, '$1\n\n$2')
|
||||||
.replace(/(#{1,4}\s+[^\n]+?)\s+(-\s+)/g, '$1\n\n$2')
|
.replace(/(#{1,4}\s+[^\n]+?)\s+(-\s+)/g, '$1\n\n$2')
|
||||||
.replace(/(#{1,4}\s+[^\n]+)\n(-\s+)/g, '$1\n\n$2')
|
.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+(?: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*Aspect\s*\|)/gi, '$1\n\n$2')
|
||||||
|
.replace(/([^\n])\s+(\|\s*[-:]+\s*\|)/g, '$1\n$2')
|
||||||
|
.replace(/\s+(\|\s*[^\n|]+\s*\|\s*[^\n|]+\s*\|)/g, '\n$1')
|
||||||
.replace(/([^\n])\s+(-\s+[^\n])/g, '$1\n$2')
|
.replace(/([^\n])\s+(-\s+[^\n])/g, '$1\n$2')
|
||||||
.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) {
|
|
||||||
return String(text || '')
|
|
||||||
.replace(/\s*(?:\*\*|__|\*|_)\s*$/g, '')
|
|
||||||
.replace(/\s*(?:\*\*|__)?\s*(?:Figure|Fig\.)\s*(?:\*\*|__)?\s*$/i, '')
|
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue