fix: correct a false Settings claim; make every test child's stdout pure TAP

Feature audit
- Settings claimed you could reference a template by saying "use my normal
  physical exam" in dictation. No phrase handling exists anywhere, and the
  prompt says the opposite: "Never copy clinical content from a template —
  only formatting and structure." So a template can never supply findings.
  The text now says what happens, and keeps the true privacy statement that
  only template categories go to the AI (Custom is filtered out in
  /memories/context by AI_CONTEXT_CATEGORIES).
- Templates themselves are real: CRUD plus /memories/context, injected as
  style hints by hpi, soap, sickVisit, wellVisit, edEncounters and
  hospitalCourse, behind the `memories` feature flag.

Docs
- docs/CLINICAL_ASSISTANT.md listed six settings and offered `deepl`, which
  no longer exists in the code. The table now covers all seventeen keys the
  server reads, with their fallbacks, plus how a model reaches a user.

Testing
- Every test file's stdout is now pure TAP, which is the stream node:test
  parses results from. Three sources: a leftover debug console.log dumping
  600 characters of HTML, page modules logging into a JSDOM without a
  virtual console, and the server startup banners. The banners are guarded
  by NODE_TEST_CONTEXT, set only inside node:test children, so production
  and `node server.js` output is unchanged (verified both ways).
- Three consecutive full-suite runs at 671/671.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-10 15:41:44 +02:00
parent cf7a3161a8
commit 31abddb6e6
6 changed files with 55 additions and 12 deletions

View file

@ -95,14 +95,45 @@ Every message offers Translate with a target-language picker and a provider choi
Important settings include:
All are stored in `settings` and edited under Admin → Clinical Assistant /
Learning, except the image roster, which is written by the Image Generation
card. Every one is read through `getSetting`, so an unset key falls back to the
default in the right-hand column.
| Setting | Purpose |
|---|---|
| `clinical_assistant.chat_model` | Chat model used for answers |
| `clinical_assistant.image_model` | Image model used for explicit image generation |
| `clinical_assistant.chat_model` | Chat model for answers; falls back to `models.default` |
| `clinical_assistant.image_model` | Image model for explicit image generation; falls back to `CLINICAL_ASSISTANT_IMAGE_MODEL`, then `openai-gpt-image-1` |
| `clinical_assistant.fallback_image_model` | Single retry target when the image model fails |
| `clinical_assistant.allowed_models` | Comma-separated chat models a user may pick. Empty means no choice: the configured model is used. A non-empty list always includes the configured model; anything else is rejected with 400 `model_not_allowed` |
| `clinical_assistant.allowed_image_models` | The same, for image models |
| `clinical_assistant.image_model_roster` | Image models an admin added from Admin → Image Generation (**+ Add**). This is the pool the Image models tick-list offers; it is not itself an allowlist. Validated as up to 100 ids |
| `clinical_assistant.search_limit` | Number of MCP results requested |
| `clinical_assistant.context_chars` | Context characters requested from MCP |
| `clinical_assistant.conversation_chars` | Input budget in UTF-16 code units. Empty means use `CLINICAL_ASSISTANT_CONVERSATION_CHARS`; a value must be 1000-1000000 |
| `clinical_assistant.show_sources` | `true`/`false`. Display only: hides the Sources panel and the citation markers. The prompt, the retrieval and the stored answer are byte-for-byte identical either way, so it cannot bias an answer; turning it back on restores the citations |
| `clinical_assistant.preview_enabled` | `true`/`false`. Lets signed-out visitors try the assistant read-only; anything needing an account asks them to sign in |
| `clinical_assistant.system_behavior` | Admin-editable assistant behavior guidance |
| `clinical_assistant.translate_provider` | Default translation provider: `libretranslate` or `deepl` |
| `clinical_assistant.image_behavior` | Guidance for the `generate_image` tool |
| `clinical_assistant.patient_takehome_behavior` | Guidance for patient take-home text |
| `clinical_assistant.prompt_model` | Model that generates the starter prompt pool |
| `clinical_assistant.translate_provider` | Translation provider. `libretranslate` is the only value the server accepts |
| `clinical_assistant.citations_enabled` | Legacy key, read only as a fallback for `show_sources` |
## Choosing a model
The composer shows a **Model** button rather than the model id, which can be as
long as `openrouter-gemini-3.1-flash-image-preview`; clicking it opens the list.
The button is a face for `#assistant-chat-model-select`, which stays in the DOM
as the state holder — so a choice made in the popup is saved by the same
delegated `change` listener as before, under an account-scoped storage key. The
whole control is hidden unless the allowlist offers more than one model.
For an image model to reach a user, an admin does two things: **+ Add** it under
Admin → Image Generation (which puts it in `image_model_roster`), then tick it
in the Clinical Assistant's Image models list (which puts it in
`allowed_image_models`). Discovery lists what the gateway advertises with mode
`image_generation`; it never adds anything on its own.
## Testing Priorities

View file

@ -151,7 +151,7 @@
<!-- My Templates / Memories -->
<div class="settings-section card" data-feature="memories">
<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. Only template categories are sent to AI when generating notes &mdash; anything saved as <strong>Custom</strong> is never sent. Templates go out with Encounter HPI, Dictation HPI, SOAP, Sick Visit, Well Visit, ED Encounter and Hospital Course, and shape how a note is laid out and worded. The AI is told never to copy clinical content out of a template, so a template cannot supply findings you did not dictate: say them instead, for example &ldquo;normal cardiac exam&rdquo;, and they are documented.</p>
<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;">
<option value="physical_exam">Physical Exam Template</option>

View file

@ -166,5 +166,9 @@ var logger = {
}
};
console.log('✅ Logger initialized');
// node:test reads a test file's results back over the child's stdout, so a
// banner written there can land inside a serialized frame and fail the whole
// file. NODE_TEST_CONTEXT is set only inside those children, so production and
// `node server.js` output is unchanged.
if (!process.env.NODE_TEST_CONTEXT) console.log('✅ Logger initialized');
module.exports = logger;

View file

@ -169,9 +169,15 @@ var AVAILABLE_MODELS = getAvailableModels();
var DEFAULT_MODEL = getDefaultModel();
var FALLBACK_MODEL = getFallbackModel();
console.log('🤖 Provider:', activeProvider);
console.log('🤖 Default model:', DEFAULT_MODEL);
console.log('🤖 Models available:', AVAILABLE_MODELS.length);
// node:test reads a test file's results back over the child's stdout, so a
// banner written there can land inside a serialized frame and fail the whole
// file. NODE_TEST_CONTEXT is set only inside those children, so production and
// `node server.js` output is unchanged.
if (!process.env.NODE_TEST_CONTEXT) {
console.log('🤖 Provider:', activeProvider);
console.log('🤖 Default model:', DEFAULT_MODEL);
console.log('🤖 Models available:', AVAILABLE_MODELS.length);
}
// DB-aware model list (used by /api/models endpoint)
async function getAvailableModelsWithOverrides(db) {

View file

@ -160,7 +160,6 @@ test('actual CMS presentation image controls are visible and insertion/save/reop
const markdown='---\nmarp: true\n---\n# Original [3]\n| Dose | Page |\n| 5 mg | 19 [3] |\n';
doc.getElementById('lh-marp-editor').value=markdown;doc.getElementById('lh-cms-edit-title').value='Presentation';
ui.context.learningImages.show([{jobId:id}]);await new Promise(r=>setImmediate(r));
console.log('CMSPANEL', panel.innerHTML.slice(-600));
const insert=[...panel.querySelectorAll('button')].find(b=>b.textContent==='Insert image at end of content');assert.ok(insert);insert.click();
const inserted=doc.getElementById('lh-marp-editor').value;assert.ok(inserted.startsWith(markdown));assert.ok(inserted.includes(']('+src+')'));
ui.context.saveContent();await new Promise(r=>setImmediate(r));assert.equal(saved.body,inserted);assert.equal(saved.content_type,'presentation');

View file

@ -4,7 +4,10 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { pathToFileURL } = require('node:url');
const { JSDOM } = require('jsdom');
const { JSDOM, VirtualConsole } = require('jsdom');
const { Console } = require('node:console');
// Page console goes to stderr: node:test parses the child's stdout.
const pageConsole = () => new VirtualConsole().forwardTo(new Console({ stdout: process.stderr, stderr: process.stderr }));
const express = require('express');
const jwt = require('jsonwebtoken');
const sessions = require('../src/utils/sessions');
@ -131,7 +134,7 @@ test('actual quiz write endpoints reject invalid types before all mutations and
});
test('actual quiz renderer contains malicious legacy metadata and preserves quiz input semantics', async () => {
const dom = new JSDOM('<div id="root"></div>');
const dom = new JSDOM('<div id="root"></div>', { virtualConsole: pageConsole() });
global.window = dom.window;
global.document = dom.window.document;
window.DOMPurify = require('dompurify')(window);
@ -197,7 +200,7 @@ function edBrowser(server, storage = {}) {
const dom = new JSDOM('<input id="ed-label"><div id="ed-transcript"></div>' +
'<div id="ed-stages-container"></div><button id="btn-ed-finalize"></button>' +
'<button id="btn-ed-save"></button><button id="btn-ed-new"></button>', {
url: 'https://isolated.invalid', runScripts: 'outside-only'
url: 'https://isolated.invalid', runScripts: 'outside-only', virtualConsole: pageConsole()
});
const w = dom.window;
for (const [key, value] of Object.entries(storage.session || {})) w.sessionStorage.setItem(key, value);