docs refresh current workflows

This commit is contained in:
Daniel 2026-05-09 00:40:45 +02:00
parent 19c9e8ba1e
commit 0a96efdaca
18 changed files with 702 additions and 9316 deletions

View file

@ -1,13 +1,18 @@
# AI providers
All AI calls flow through `callAI(messages, options)` in `src/utils/ai.js`.
Provider is selected once at startup and is transparent to callers.
Provider is selected at startup and is transparent to route handlers.
## Provider selection
1. If `AI_PROVIDER` env var is set, use it.
2. Otherwise, check credentials in priority order:
`bedrock > azure > vertex > litellm > openrouter`.
1. If `AI_PROVIDER` is set, it chooses `bedrock`, `azure`, `vertex`,
`litellm`, or `openrouter` explicitly.
2. If `AI_PROVIDER` is unset, `ai.js` initializes every configured client and
the last configured non-OpenRouter provider wins in current load order:
Bedrock → Azure → Vertex → LiteLLM. If none of those are configured,
OpenRouter is the default.
3. If the selected provider cannot initialize, the code falls back to
OpenRouter and surfaces an error if `OPENROUTER_API_KEY` is missing.
## Providers

View file

@ -1,6 +1,6 @@
# API Reference
Complete endpoint reference for the PedAI application. All endpoints are prefixed with the application base URL. Unless noted otherwise, authenticated endpoints require a valid JWT token passed via cookie or `Authorization: Bearer <token>` header.
Working endpoint reference for the main PedAI flows. It covers the clinical, auth, Learning Hub, user data, and admin surfaces most commonly used by the frontend, but the source of truth is still `server.js` plus `src/routes/*.js`. Unless noted otherwise, authenticated endpoints require a valid web cookie or `Authorization: Bearer <token>` header.
---
@ -36,7 +36,7 @@ Base path: `/api/auth/`
### POST /api/auth/register
Register a new user account. The first user to register is automatically assigned the admin role. Requires Cloudflare Turnstile verification. A verification email is sent on success.
Register a new user account. The first user to register is automatically assigned the admin role. Turnstile is required only when `TURNSTILE_SECRET_KEY` is configured. If SMTP is configured, the response asks the user to verify email; without SMTP, the account is auto-verified and logged in.
- **Auth required:** No
- **Request body:**
@ -52,7 +52,8 @@ Register a new user account. The first user to register is automatically assigne
```json
{
"success": true,
"message": "Registration successful. Please check your email to verify your account."
"needsVerification": true,
"message": "Check your email for verification link."
}
```
@ -67,7 +68,7 @@ Verify a user's email address via the link sent during registration.
| Parameter | Type | Description |
|-----------|--------|--------------------------------------|
| `token` | string | Email verification token from the link |
- **Response:** Redirects to the login page with a success or error message.
- **Response:** HTML status page for success or expired/invalid token.
---
@ -111,11 +112,14 @@ Authenticate a user. Supports optional TOTP two-factor authentication. On succes
{
"success": true,
"token": "jwt-string",
"sessionId": "string",
"user": {
"id": "number",
"name": "string",
"email": "string",
"role": "admin | moderator | user"
"role": "admin | moderator | user",
"totp_enabled": "boolean",
"email_verified": "boolean"
}
}
```
@ -137,6 +141,7 @@ Generate a TOTP secret and QR code for setting up two-factor authentication.
- **Response:**
```json
{
"success": true,
"secret": "string",
"qrCode": "string (data URI)"
}
@ -159,7 +164,7 @@ Verify a TOTP code and enable two-factor authentication for the user.
```json
{
"success": true,
"message": "2FA enabled successfully."
"backupCodes": ["string"]
}
```
@ -179,8 +184,7 @@ Disable two-factor authentication. Requires password confirmation.
- **Response:**
```json
{
"success": true,
"message": "2FA disabled successfully."
"success": true
}
```
@ -202,7 +206,7 @@ Initiate password reset. Sends a reset link to the user's email.
```json
{
"success": true,
"message": "If an account exists with that email, a reset link has been sent."
"message": "If account exists, reset email sent"
}
```
@ -217,14 +221,14 @@ Reset the user's password using a valid reset token.
```json
{
"token": "string",
"password": "string"
"newPassword": "string"
}
```
- **Response:**
```json
{
"success": true,
"message": "Password reset successfully."
"passwordWarning": "string (optional)"
}
```
@ -232,15 +236,14 @@ Reset the user's password using a valid reset token.
### POST /api/auth/logout
Log out the current user by clearing the JWT cookie.
Log out the current user by deleting the current session row when a token is present and clearing the auth cookie.
- **Auth required:** No
- **Request body:** None
- **Response:**
```json
{
"success": true,
"message": "Logged out."
"success": true
}
```
@ -254,11 +257,15 @@ Retrieve the currently authenticated user's profile.
- **Response:**
```json
{
"id": "number",
"name": "string",
"email": "string",
"role": "admin | moderator | user",
"twoFactorEnabled": "boolean"
"user": {
"id": "number",
"name": "string",
"email": "string",
"role": "admin | moderator | user",
"totp_enabled": "boolean",
"email_verified": "boolean",
"canLocalAuth": "boolean"
}
}
```
@ -272,7 +279,7 @@ Check whether new user registration is enabled on this instance.
- **Response:**
```json
{
"enabled": true
"registrationEnabled": true
}
```
@ -337,7 +344,7 @@ Generate a History of Present Illness note from a patient encounter transcript.
"physicianMemories": "string (optional)"
}
```
- **Response:** Streamed text (text/event-stream) or JSON with the generated HPI note.
- **Response:** JSON with `success`, generated `hpi`, and resolved `model`.
---
@ -357,7 +364,7 @@ Generate an HPI note from a physician's dictated summary (same parameters as enc
"physicianMemories": "string (optional)"
}
```
- **Response:** Streamed text (text/event-stream) or JSON with the generated HPI note.
- **Response:** JSON with `success`, generated `hpi`, and resolved `model`.
---
@ -378,7 +385,7 @@ Generate a SOAP note from a patient encounter transcript.
"physicianMemories": "string (optional)"
}
```
- **Response:** Streamed text (text/event-stream) or JSON with the generated SOAP note.
- **Response:** JSON with `success`, generated `soap`, and resolved `model`.
---
@ -395,7 +402,7 @@ Generate a chart review summary from clinical notes.
"model": "string"
}
```
- **Response:** Streamed text or JSON with the chart review.
- **Response:** JSON with `success`, generated chart review content, and resolved `model`.
---
@ -415,7 +422,7 @@ Generate a hospital course summary from clinical notes.
"physicianMemories": "string (optional)"
}
```
- **Response:** Streamed text or JSON with the hospital course summary.
- **Response:** JSON with `success`, generated summary, and resolved `model`.
---
@ -432,7 +439,7 @@ Generate a SHADESS (Strengths, Home, Activities, Drugs, Emotions, Sexuality, Saf
"model": "string"
}
```
- **Response:** Streamed text or JSON with the SHADESS assessment.
- **Response:** JSON with `success`, generated assessment, and resolved `model`.
---
@ -456,7 +463,7 @@ Generate a sick visit note from chief complaint, transcript/dictation, ROS/PE, a
"physicianMemories": "string (optional)"
}
```
- **Response:** Streamed text or JSON with the sick visit note.
- **Response:** JSON with `success`, generated note, and resolved `model`.
---
@ -502,7 +509,7 @@ Generate a developmental milestone narrative from milestone data.
"model": "string"
}
```
- **Response:** Streamed text or JSON with the milestone narrative.
- **Response:** JSON with `success`, generated narrative, and resolved `model`.
---
@ -518,7 +525,7 @@ Generate a concise summary from a previously generated milestone narrative.
"model": "string"
}
```
- **Response:** Streamed text or JSON with the milestone summary.
- **Response:** JSON with `success`, generated summary, and resolved `model`.
---
@ -536,7 +543,7 @@ Refine existing clinical text according to provided instructions.
"model": "string"
}
```
- **Response:** Streamed text or JSON with the refined text.
- **Response:** JSON with `success`, refined text, and resolved `model`.
---
@ -552,7 +559,7 @@ Shorten a block of clinical text while preserving key information.
"model": "string"
}
```
- **Response:** Streamed text or JSON with the shortened text.
- **Response:** JSON with `success`, shortened text, and resolved `model`.
---
@ -569,7 +576,7 @@ Improve clarity and readability of clinical text.
"model": "string"
}
```
- **Response:** Streamed text or JSON with the clarified text.
- **Response:** JSON with `success`, clarified text, and resolved `model`.
---
@ -664,20 +671,27 @@ List available AI models for the current instance.
### GET /api/encounters/saved
List all saved encounters for the authenticated user.
List active, unexpired saved encounters for the authenticated user.
- **Auth required:** Yes
- **Response:**
```json
[
{
"id": "number",
"label": "string",
"enc_type": "string",
"created_at": "string (ISO 8601)",
"updated_at": "string (ISO 8601)"
}
]
{
"success": true,
"encounters": [
{
"id": "number",
"label": "string",
"enc_type": "string",
"status": "string",
"created_at": "string (ISO 8601)",
"updated_at": "string (ISO 8601)",
"expires_at": "string (ISO 8601)",
"transcript_preview": "string",
"note_preview": "string"
}
]
}
```
---
@ -694,14 +708,17 @@ Retrieve a single saved encounter with full data.
- **Response:**
```json
{
"id": "number",
"label": "string",
"enc_type": "string",
"transcript": "string",
"generated_note": "string",
"partial_data": "object | null",
"created_at": "string (ISO 8601)",
"updated_at": "string (ISO 8601)"
"success": true,
"encounter": {
"id": "number",
"label": "string",
"enc_type": "string",
"transcript": "string",
"generated_note": "string",
"partial_data": "stringified JSON",
"created_at": "string (ISO 8601)",
"updated_at": "string (ISO 8601)"
}
}
```
@ -709,25 +726,29 @@ Retrieve a single saved encounter with full data.
### POST /api/encounters/saved
Create or update a saved encounter. Uses `idempotency_key` to prevent duplicates.
Create or update a saved encounter. Uses `idempotency_key` to prevent duplicates on create and `expected_version` for optimistic locking on updates.
- **Auth required:** Yes
- **Request body:**
```json
{
"id": "number (optional, update existing encounter)",
"label": "string",
"enc_type": "string",
"transcript": "string",
"generated_note": "string",
"partial_data": "object (optional)",
"idempotency_key": "string (optional)"
"status": "string (optional)",
"idempotency_key": "string (optional)",
"expected_version": "number (optional, update only)"
}
```
- **Response:**
```json
{
"success": true,
"id": "number"
"id": "number",
"version": "number (updates only)"
}
```
@ -753,7 +774,7 @@ Delete a saved encounter.
## Memories
Physician memories are reusable context snippets (preferences, style corrections, common instructions) injected into AI generation prompts.
Physician memories are encrypted user templates and prompt preferences. Only AI-context categories are injected into generation prompts; `custom` and legacy `correction_*` rows are excluded from `/api/memories/context`.
### GET /api/memories
@ -762,15 +783,19 @@ List all memories for the authenticated user. Returns up to 200 entries.
- **Auth required:** Yes
- **Response:**
```json
[
{
"id": "number",
"category": "string",
"name": "string",
"content": "string",
"created_at": "string (ISO 8601)"
}
]
{
"success": true,
"memories": [
{
"id": "number",
"category": "string",
"name": "string",
"content": "string",
"created_at": "string (ISO 8601)",
"updated_at": "string (ISO 8601)"
}
]
}
```
---
@ -792,7 +817,9 @@ Create a new memory.
```json
{
"success": true,
"id": "number"
"id": "number",
"originalSize": "number",
"compressedSize": "number"
}
```
@ -860,11 +887,11 @@ generation prompts. `custom` rows and legacy `correction_*` rows are excluded.
## Audio Backups
Temporary audio backup storage with automatic 24-hour expiry.
Temporary encrypted audio backup storage with automatic 24-hour expiry.
### POST /api/audio-backups
Upload an audio backup. The file is gzip-compressed on the server.
Upload an audio backup. The file is gzip-compressed and encrypted on the server.
- **Auth required:** Yes
- **Content-Type:** `multipart/form-data`
@ -884,19 +911,25 @@ Upload an audio backup. The file is gzip-compressed on the server.
### GET /api/audio-backups
List all audio backups for the authenticated user.
List unexpired audio backups for the authenticated user.
- **Auth required:** Yes
- **Response:**
```json
[
{
"id": "number",
"filename": "string",
"created_at": "string (ISO 8601)",
"expires_at": "string (ISO 8601)"
}
]
{
"success": true,
"backups": [
{
"id": "number",
"module": "string",
"mime_type": "string",
"size_bytes": "number",
"compressed_bytes": "number",
"created_at": "string (ISO 8601)",
"expires_at": "string (ISO 8601)"
}
]
}
```
---
@ -943,15 +976,20 @@ List all documents for the authenticated user.
- **Auth required:** Yes
- **Response:**
```json
[
{
"id": "number",
"filename": "string",
"mimetype": "string",
"size": "number",
"created_at": "string (ISO 8601)"
}
]
{
"success": true,
"s3_configured": true,
"documents": [
{
"id": "number",
"filename": "string",
"mime_type": "string",
"size_bytes": "number",
"description": "string",
"created_at": "string (ISO 8601)"
}
]
}
```
---
@ -963,7 +1001,7 @@ Upload a document to S3 storage.
- **Auth required:** Yes
- **Content-Type:** `multipart/form-data`
- **File size limit:** 10 MB
- **Allowed types:** PDF, images (JPEG, PNG, GIF, WebP), Word documents (.doc, .docx), plain text (.txt), CSV (.csv)
- **Allowed types:** PDF, images (JPEG, PNG, GIF), Word documents (.doc, .docx), plain text (.txt), CSV (.csv)
- **Form fields:**
| Field | Type | Description |
|--------|------|---------------------|
@ -988,7 +1026,13 @@ Download a document.
| Parameter | Type | Description |
|-----------|--------|---------------|
| `id` | number | Document ID |
- **Response:** Binary file stream with appropriate content-type and content-disposition headers.
- **Response:** JSON containing a short-lived pre-signed download URL.
```json
{
"success": true,
"url": "string"
}
```
---
@ -1020,6 +1064,7 @@ Get the authenticated user's preferences.
- **Response:**
```json
{
"success": true,
"stt_model": "string",
"tts_voice": "string"
}
@ -1056,12 +1101,15 @@ List available STT models and TTS voices that the user can choose from.
- **Response:**
```json
{
"success": true,
"sttModels": [
{ "id": "string", "name": "string" }
{ "value": "string", "label": "string" }
],
"ttsVoices": [
{ "id": "string", "name": "string" }
]
{ "value": "string", "label": "string" }
],
"sttProvider": "string",
"ttsProvider": "string"
}
```
@ -1873,17 +1921,21 @@ List all registered users.
- **Auth required:** Yes (admin)
- **Response:**
```json
[
{
"id": "number",
"name": "string",
"email": "string",
"role": "string",
"verified": "boolean",
"disabled": "boolean",
"created_at": "string (ISO 8601)"
}
]
{
"success": true,
"users": [
{
"id": "number",
"name": "string",
"email": "string",
"role": "string",
"email_verified": "boolean",
"totp_enabled": "boolean",
"disabled": "boolean",
"created_at": "string (ISO 8601)"
}
]
}
```
---
@ -1900,16 +1952,17 @@ Get detailed information and usage statistics for a specific user.
- **Response:**
```json
{
"id": "number",
"name": "string",
"email": "string",
"role": "string",
"verified": "boolean",
"disabled": "boolean",
"stats": {
"totalGenerations": "number",
"totalTranscriptions": "number",
"lastActive": "string (ISO 8601)"
"success": true,
"user": {
"id": "number",
"name": "string",
"email": "string",
"role": "string",
"email_verified": "boolean",
"totp_enabled": "boolean",
"disabled": "boolean",
"api_calls": "number",
"last_login": "string (ISO 8601) | null"
}
}
```
@ -1976,13 +2029,16 @@ Requires admin role unless noted otherwise.
### GET /api/admin/config/announcement
Get the current announcement banner. This endpoint is public and does not require authentication.
Get the current announcement banner. Any authenticated user can read this endpoint.
- **Auth required:** No
- **Auth required:** Yes
- **Response:**
```json
{
"announcement": "string | null"
"success": true,
"enabled": "boolean",
"text": "string",
"type": "info | warning | error | success"
}
```
@ -1997,12 +2053,17 @@ Get all application configuration settings.
---
### POST /api/admin/config
### PUT /api/admin/config/:key
Update application configuration settings.
Update one application configuration setting. The key must use an allowed prefix such as `announcement.`, `feature.`, `email.`, `prompt.`, `registration_enabled`, `site.`, `smtp.`, `models.`, `tts.`, `stt.`, `embeddings.`, or `clinical_assistant.`.
- **Auth required:** Yes (admin)
- **Request body:** Object with configuration key-value pairs to update.
- **Request body:**
```json
{
"value": "string"
}
```
- **Response:**
```json
{
@ -2042,21 +2103,6 @@ Get all AI prompt templates, including any admin overrides.
---
### POST /api/admin/config/prompts
Save AI prompt template overrides.
- **Auth required:** Yes (admin)
- **Request body:** Object mapping prompt keys to new values.
- **Response:**
```json
{
"success": true
}
```
---
### POST /api/admin/config/prompts/:key/reset
Reset a specific AI prompt template back to its default value.
@ -2099,13 +2145,16 @@ Check the current SMTP configuration status.
{
"configured": "boolean",
"host": "string",
"port": "number"
"port": "string",
"user": "string",
"from": "string",
"source": "env | database | none"
}
```
---
### POST /api/admin/config/smtp/update
### PUT /api/admin/config/smtp
Update SMTP email configuration.
@ -2115,9 +2164,10 @@ Update SMTP email configuration.
{
"host": "string",
"port": "number",
"username": "string",
"password": "string",
"from": "string"
"user": "string",
"pass": "string",
"from": "string",
"secure": "boolean"
}
```
- **Response:**
@ -2129,7 +2179,7 @@ Update SMTP email configuration.
---
### POST /api/admin/config/registration
### POST /api/admin/settings/registration
Update registration settings (enable/disable new registrations).
@ -2149,7 +2199,7 @@ Update registration settings (enable/disable new registrations).
---
### GET /api/admin/config/oidc
### GET /api/auth/oidc/config
Get the current OIDC/SSO configuration.
@ -2157,17 +2207,21 @@ Get the current OIDC/SSO configuration.
- **Response:**
```json
{
"enabled": "boolean",
"clientId": "string",
"issuerUrl": "string",
"buttonLabel": "string",
"disableLocalAuth": "boolean"
"success": true,
"config": {
"oidc.enabled": "string",
"oidc.issuer": "string",
"oidc.client_id": "string",
"oidc.client_secret": "masked string",
"oidc.disable_local_auth": "string",
"oidc.button_label": "string"
}
}
```
---
### POST /api/admin/config/oidc
### PUT /api/auth/oidc/config
Update the OIDC/SSO configuration.
@ -2175,12 +2229,12 @@ Update the OIDC/SSO configuration.
- **Request body:**
```json
{
"enabled": "boolean",
"clientId": "string",
"clientSecret": "string",
"issuerUrl": "string",
"buttonLabel": "string",
"disableLocalAuth": "boolean"
"oidc.enabled": "string",
"oidc.issuer": "string",
"oidc.client_id": "string",
"oidc.client_secret": "string",
"oidc.disable_local_auth": "string",
"oidc.button_label": "string"
}
```
- **Response:**
@ -2283,6 +2337,7 @@ Discover available models from the configured AI provider.
- **Response:**
```json
{
"success": true,
"models": [
{
"id": "string",
@ -2390,9 +2445,7 @@ Application health check endpoint.
- **Response:**
```json
{
"status": "ok",
"provider": "string",
"uptime": "number (seconds)"
"ok": true
}
```

View file

@ -29,11 +29,11 @@ keys):
| Variable | Purpose |
|---|---|
| `AI_PROVIDER` | `openrouter` / `bedrock` / `azure` / `vertex` / `litellm`. Auto-detected by credential presence if unset. |
| `AI_PROVIDER` | `openrouter` / `bedrock` / `azure` / `vertex` / `litellm`. If unset, the startup loader uses configured credentials and the last initialized provider in Bedrock → Azure → Vertex → LiteLLM order wins; otherwise OpenRouter is the default. |
| `OPENROUTER_API_KEY` | OpenRouter key (not HIPAA-eligible). |
| `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Bedrock / Transcribe / Transcribe-Medical. |
| `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_VERSION` | Azure OpenAI. |
| `GOOGLE_VERTEX_PROJECT`, `GOOGLE_VERTEX_LOCATION`, `GOOGLE_APPLICATION_CREDENTIALS` | Vertex AI + Gemini (STT/TTS). |
| `GOOGLE_VERTEX_PROJECT`, `GOOGLE_VERTEX_LOCATION`, `GOOGLE_APPLICATION_CREDENTIALS` | Vertex AI + Gemini chat/STT/TTS. |
| `LITELLM_API_BASE`, `LITELLM_API_KEY` | OpenAI-compatible AI gateway (Bifrost, LiteLLM, or similar). |
### Speech-to-text
@ -60,8 +60,9 @@ keys):
| Variable | Purpose |
|---|---|
| `EMBEDDING_MODEL` | Embedding model name (default `text-embedding-005`, Vertex). |
| `EMBEDDING_MODEL` | Embedding model name (default `vertex_ai/text-embedding-005`). |
| `EMBEDDING_DIMENSIONS` | Vector dimensions (default 768). |
| `VERTEX_PROJECT`, `GOOGLE_CLOUD_PROJECT` | Direct Vertex embedding project. Embedding utility also works through LiteLLM when `LITELLM_API_BASE` is set. |
### Email (SMTP)

File diff suppressed because it is too large Load diff

View file

@ -275,7 +275,7 @@ docker exec -w /app pediatric-ai-scribe npm run migrate:new -- add_my_table
| `transcribe.js` | `/api` | Auth | STT (5 providers) |
| `tts.js` | `/api` | Auth | TTS (3 providers) |
| `encounters.js` | `/api` | Auth | Save / load / optimistic-lock encounters |
| `memories.js` | `/api` | Auth | Templates + corrections |
| `memories.js` | `/api` | Auth | Templates + prompt preferences |
| `audioBackups.js` | `/api` | Auth | Encrypted audio retry store |
| `documents.js` | `/api` | Auth | S3 documents (magic-byte checked) |
| `userPreferences.js` | `/api` | Auth | Per-user STT/TTS choice |
@ -302,10 +302,8 @@ docker exec -w /app pediatric-ai-scribe npm run migrate:new -- add_my_table
| `milestones.js` + `milestonesData.js` | Milestones tab |
| `shadess.js` | SSHADESS adolescent assessment |
| `encounters.js` | Save / load / resume with optimistic lock |
| `memories.js` | Physician templates + corrections UI |
| `correctionTracker.js` | Captures AI-output edits |
| `memories.js` | Physician templates and prompt preferences UI |
| `speechRecognition.js` | Explicit opt-in browser Web Speech support |
| `speechRecognition.js` | Web Speech API preview |
| `voicePreferences.js` | Per-user STT/TTS override |
| `audioBackup.js` | Server + IndexedDB backup retries |
| `nextcloud.js` | Connect / export |

View file

@ -1,8 +1,8 @@
# Embeddings & Semantic Search Setup
# Embeddings And Semantic Search Setup
This guide explains how to set up and use the new vector-based semantic search for the Learning Hub.
## 🎯 What's New
## What This Enables
- **Semantic search** - Find content by meaning, not just keywords
- **3 search modes**:
@ -12,7 +12,7 @@ This guide explains how to set up and use the new vector-based semantic search f
- **Auto-embedding** - Content is automatically vectorized when created/updated
- **HIPAA-compliant** - Uses Vertex AI embeddings (BAA available)
## 📋 Prerequisites
## Prerequisites
### 1. Install pgvector Extension
@ -56,20 +56,20 @@ EMBEDDING_MODEL=text-embedding-005 # LiteLLM will route to configured provider
# Option 3: OpenAI (NOT HIPAA-eligible, fallback only)
OPENAI_API_KEY=sk-your-key
# Uses text-embedding-3-small automatically
# Uses text-embedding-3-small and truncates to the 768-dimension column
```
## 🚀 Available Vertex AI Embedding Models
## Available Vertex AI Embedding Models
Tested and working via LiteLLM:
| Model | Dimensions | Use Case | HIPAA |
|-------|-----------|----------|-------|
| **vertex_ai/text-embedding-005** | 768 | English + code (recommended) | Yes |
| **vertex_ai/gemini-embedding-001** | 768-3072 | Multilingual + code, best quality | Yes |
| **vertex_ai/text-multilingual-embedding-002** | 768 | Multilingual focus | Yes |
| **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
@ -118,7 +118,7 @@ Response:
}
```
## 🔍 Using Semantic Search
## Using Semantic Search
### Keyword Search (existing)
```bash
@ -144,7 +144,7 @@ GET /api/learning/search/hybrid?q=fever management
```
Combines keyword + semantic for best results. Automatically deduplicates and ranks by relevance.
## 🔬 How It Works
## How It Works
1. **Content Creation/Update**:
- Text is extracted from `title`, `subject`, and `body` (HTML stripped)
@ -164,13 +164,7 @@ Combines keyword + semantic for best results. Automatically deduplicates and ran
- Deduplicates by content ID
- Sorts by relevance score
## 💰 Cost Estimate (Vertex AI)
**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
## Cost Estimate
**Google Vertex AI pricing:**
- text-embedding-005: $0.025 per 1M characters
@ -178,14 +172,14 @@ Combines keyword + semantic for best results. Automatically deduplicates and ran
- 1,000 articles: ~**$0.25 one-time**
- Search queries: ~$0.0000125 per query
## 🐛 Troubleshooting
## Troubleshooting
### "pgvector extension not available"
- Install: `apt-get install postgresql-16-pgvector`
- For Docker: Use `pgvector/pgvector:pg16` image
### "Embeddings not configured"
- Verify `.env` has `VERTEX_PROJECT` or `LITELLM_API_BASE` or `OPENAI_API_KEY`
- Verify `.env` has `VERTEX_PROJECT` / `GOOGLE_CLOUD_PROJECT`, `LITELLM_API_BASE`, or `OPENAI_API_KEY`
- Check service account credentials: `GOOGLE_APPLICATION_CREDENTIALS`
- Test: `curl http://localhost:3000/api/admin/learning/embeddings/status`
@ -200,7 +194,7 @@ Combines keyword + semantic for best results. Automatically deduplicates and ran
- Lower threshold: `?threshold=0.3` (default 0.5)
- Verify pgvector index exists: `\di` in psql
## 📊 Performance
## Performance
- **Embedding generation**: ~500ms per article (Vertex AI)
- **Search latency**:
@ -209,14 +203,14 @@ Combines keyword + semantic for best results. Automatically deduplicates and ran
- Hybrid: 30-150ms
- **Index build time**: ~1-5 seconds per 1,000 articles
## 🔐 Security & Compliance
## Security And Compliance
- **HIPAA-eligible**: Vertex AI supports BAA (Business Associate Agreement)
- **Data retention**: Embeddings stored in your database only
- **No PHI**: Only article content (not patient data) is embedded
- **Encryption**: TLS in transit, at-rest encryption via PostgreSQL
## 🎓 Example Queries
## Example Queries
**Before (keyword):**
```
@ -244,7 +238,7 @@ Results:
- Bronchiolitis vs asthma (keyword: 1.0)
```
## 📚 API Reference
## API Reference
### Admin Endpoints

View file

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

View file

@ -3,7 +3,7 @@
> Deep, dev-friendly documentation of how each part of the ped-ai app
> actually works. Written so a human developer can understand the
> codebase without spelunking, and so an AI assistant can confidently
> modify code without breaking sacred zones.
> modify code without breaking high-risk workflows.
These docs explain **application logic** — what the user does, what the
system does in response, what the data flow is, and **why** the design
@ -16,9 +16,9 @@ recipes (see [`../deployment.md`](../deployment.md)).
For someone brand new to the codebase:
1. **[architecture.md](architecture.md)** — Start here. The big picture:
IIFE frontend pattern, lazy tab loading, backend route convention,
current frontend pattern, lazy tab loading, backend route convention,
PostgreSQL schema, encryption at rest, Dockerfile + compose layout,
sacred zones. (~2,000 lines, the longest doc — but the foundation.)
and high-risk zones.
2. **[clinical-notes.md](clinical-notes.md)** — How every clinical note
tab works. The shared "record → transcribe → generate → save"
@ -33,8 +33,7 @@ For someone brand new to the codebase:
composed in this codebase. Read this for a worked example.
4. **[bedside-and-calculators.md](bedside-and-calculators.md)** —
Bedside emergencies module (the one ES-module pocket of the
frontend), the pediatric calculators (BP percentile, Fenton growth,
Bedside emergencies module, the pediatric calculators (BP percentile, Fenton growth,
bilirubin nomograms, etc.), the PE Guide, vax schedule, milestones.
Includes the suture selector. **Important:** lists every clinical
formula that must NOT be modified without test vectors.
@ -42,7 +41,7 @@ For someone brand new to the codebase:
5. **[ai-and-voice.md](ai-and-voice.md)** — AI provider routing
(`callAI`), the centralized `PROMPTS` object with DB overrides, the
`wrapUserText` + `INJECTION_GUARD` safety pattern, server-side STT
routing, server-side STT, TTS, and the AudioRecorder. Voice/STT plumbing is **sacred** — the
routing, TTS, and the AudioRecorder. Voice/STT plumbing is high-risk — the
doc describes it without proposing changes.
6. **[auth-admin-learning.md](auth-admin-learning.md)** — Authentication
@ -71,12 +70,11 @@ Each doc follows the same structure:
- **Data flow** — what HTTP calls happen, what the server does
- **File map** — which files do what
- **Key design decisions***why* it works the way it does
- **Sacred zones** — what NOT to refactor without explicit approval
- **High-risk zones** — what requires small, tested changes
- **How to extend** — concrete recipes for adding a new X
When a doc mentions a sacred zone, it means there's a project-memory
rule that this code must not be refactored without per-change approval
from Daniel. The full sacred-zone roster:
When a doc mentions a high-risk zone, changes should be small, well-tested, and
directly tied to the requested behavior. Current high-risk areas:
| Zone | Why |
|---|---|
@ -92,8 +90,8 @@ A few topics span multiple docs. Use these as your jump-off points:
| Topic | Where to look |
|---|---|
| The IIFE pattern + `window.x = y` cross-file globals | architecture.md §2-3 |
| Lazy tab loading (`loadComponent`, `tabChanged` event) | architecture.md §3-4 |
| Frontend globals, ES modules, and lazy tab loading | architecture.md |
| Lazy tab loading (`loadComponent`, `tabChanged` event) | architecture.md |
| `getUserMemoryContext` → templates feeding into AI prompts | clinical-notes.md §6, ed-encounters.md §9 |
| The helper trio: `refineDocument`, `suggestBillingCodes`, `suggestDontMiss` | ai-and-voice.md §12, clinical-notes.md §5 |
| `wrapUserText` + `INJECTION_GUARD` prompt-injection defense | ai-and-voice.md §5 |

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

@ -45,7 +45,7 @@ git commit -m "feat: ..." && git push # auto-version workflow bumps minor
git commit -m "fix: ..." && git push # auto-version workflow bumps patch
# or force an exact version
scripts/release.sh 6.2.0 --push
scripts/release.sh X.Y.Z --push
```
APK lands at the GitHub release; `/releases/latest` link in the login page

View file

@ -4,7 +4,7 @@
`POST /api/transcribe` accepts `multipart/form-data` with one audio file up to 25 MB. The provider is selected by `TRANSCRIBE_PROVIDER`, or auto-detected from available credentials.
Provider priority in auto mode is Google/Gemini, AWS Transcribe, LiteLLM, then OpenAI Whisper when configured.
Provider priority in auto mode is Google/Gemini, then AWS Transcribe, then direct OpenAI Whisper. Set `TRANSCRIBE_PROVIDER=litellm` or `TRANSCRIBE_PROVIDER=local` explicitly for those paths.
| Provider | Notes | HIPAA posture |
|---|---|---|

View file

@ -13,6 +13,10 @@ Use a server-side provider covered by your compliance requirements.
| Direct OpenAI Whisper | Only when acceptable for your deployment. |
| Real-time draft preview | Browser Web Speech only with explicit user opt-in and privacy warning. |
Auto-detect mode currently checks Google/Gemini first, then AWS Transcribe,
then direct OpenAI Whisper. Use `TRANSCRIBE_PROVIDER=litellm` or
`TRANSCRIBE_PROVIDER=local` when you want those providers.
## Configuration
```env

View file

@ -16,6 +16,7 @@
var _tree = [];
var _flatFiles = []; // flat list of {name, path, parent} for filter
var _expanded = {}; // map of dir-path → bool, persisted in UIState
var _currentPath = '';
function $(id) { return document.getElementById(id); }
function escHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
@ -136,27 +137,49 @@
if (!body) return;
body.querySelectorAll('a[href]').forEach(function (link) {
var href = link.getAttribute('href') || '';
if (href.charAt(0) !== '#' && link.hash) {
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;
href = u.hash;
link.dataset.docHash = u.hash;
link.classList.add('docs-anchor-link');
} catch (_) { return; }
}
if (!href || href.charAt(0) !== '#') return;
link.dataset.docHash = href;
link.removeAttribute('href');
link.setAttribute('role', 'button');
link.setAttribute('tabindex', '0');
link.classList.add('docs-anchor-link');
});
}
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 id = decodeURIComponent(String(hash).replace(/^#/, ''));
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)) {
@ -172,22 +195,34 @@
}
function handleDocAnchorClick(e) {
var link = e.target.closest('[data-doc-hash]');
var body = $('docs-reader-body');
if (!link || !body || !body.contains(link)) return;
var href = link.dataset.docHash || '';
if (!href || href.charAt(0) !== '#') return;
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;
handleDocAnchorClick(e);
if (handleDocAnchorClick(e)) e.preventDefault();
}
function loadTree() {
@ -222,10 +257,11 @@
});
}
function loadFile(relPath) {
function loadFile(relPath, hash) {
var body = $('docs-reader-body');
var meta = $('docs-reader-meta');
if (!body) return;
_currentPath = relPath;
body.innerHTML = '<div class="docs-loading">Loading…</div>';
fetch('/api/admin/docs/file?path=' + encodeURIComponent(relPath), { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
@ -245,7 +281,8 @@
// Scroll content area to top so deep-link readers don't land mid-doc
var reader = $('docs-reader');
if (reader) reader.scrollTop = 0;
if (window.location.hash) setTimeout(function () { scrollReaderToHash(window.location.hash); }, 0);
var targetHash = hash || window.location.hash;
if (targetHash) setTimeout(function () { scrollReaderToHash(targetHash); }, 0);
})
.catch(function (err) {
body.innerHTML = '<p style="color:var(--red);">' + escHtml(err.message || String(err)) + '</p>';
@ -266,6 +303,7 @@
// ── Wire events ────────────────────────────────────────────────────
function init() {
document.addEventListener('click', function (e) {
if (handleDocAnchorClick(e)) return;
var fileBtn = e.target.closest('.docs-file-btn');
if (fileBtn) {
var p = fileBtn.dataset.file;