From 67a8b4f849b019853b4f181c8b7cda4f42ee95b1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 18:38:10 +0200 Subject: [PATCH] feat: My Resources says what it is, offers its sources in one place, and Modify gets them too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen had been trimmed down to controls with no explanation of what it was for; "Private to you" said who could see it, not what it did. It now opens with a sentence that says what you get and how you get it out, and the header says "Only you can see these". The four choices — clinical library, PubMed, web, illustration — are one "Draw on" group instead of four separate rows, and the library is ticked by default. Each option hides itself when an administrator has not enabled it, so nothing appears that a person could tick and then be refused. Modify offers exactly the same choices. It had none: refining could only reword what was already there, so "add what the 2024 trial showed" was answered from the model's memory. Generate and Modify now go through one gatherSources(), so they cannot drift into offering different things or searching them differently. Writing "include a diagram of the airway" in the instructions now switches the illustration option on and says why, rather than the request being dropped in silence. Switching it off by hand sticks — the hint then reminds rather than fighting — and when no image model is configured it says so instead of pretending. Both the generate and modify boxes behave this way. Two things found by testing this rather than assuming it: PubMed ANDs every mapped term, so one unrecognised word takes the query to zero. "febrile seizures" returns six results and "febrile seizures in under-fives" returns none; "the anatomy of croup: subglottic narrowing and the steeple sign" returned none until it was narrowed to "anatomy croup", which returns six. A query that finds nothing is now retried against progressively shorter versions of itself, longest first, and the response says which query actually worked so the screen does not report a query that found nothing. Those extra calls tripped NCBI's three-a-second limit and produced a 429, so retries are spaced and the first attempt waits for nothing. Separately, the searches are run on the topic while the library retrieval gets the instruction too: retrieval is semantic and benefits from the context, but handing a whole sentence to a keyword engine returns nothing. And when a search was asked for and came back empty, the prompt now says not to invent a citation, a PMID or a URL to fill the gap. Without that the model supplies them from memory, and a fabricated PMID looks exactly like a real one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- e2e/fixtures.js | 17 ++- e2e/playwright.config.js | 6 + e2e/tests/settings-faq-dictation.spec.js | 13 +- public/components/my-resources.html | 98 +++++++------ public/css/styles.css | 2 + public/e2e-harness.html | 12 +- public/js/myResources.js | 113 ++++++++++++--- src/routes/myResources.js | 169 +++++++++++++++++------ src/utils/pubmedSearch.js | 71 ++++++++-- test/my-resources.test.js | 19 ++- test/web-search.test.js | 64 +++++++-- 11 files changed, 457 insertions(+), 127 deletions(-) diff --git a/e2e/fixtures.js b/e2e/fixtures.js index 5cec0802..2bd2a368 100644 --- a/e2e/fixtures.js +++ b/e2e/fixtures.js @@ -85,6 +85,21 @@ async function loginAs(context, request, email = TEST_EMAIL) { }]); } +// A '**/api/x' glob stopped matching any URL when Playwright went to 1.50, and +// page.route fails silently: no error, no warning, the request simply goes to +// the server. So every "mocked" AI test was calling the real model and +// comparing its genuine output against a canned string — spending real credits +// on every run and failing for a reason that looked like a UI bug. Measured: +// against http://127.0.0.1:3553/api/health, '**/api/health' and '*/**/api/health' +// both matched zero times; a regex matched. +// +// The patterns are kept as strings because they are also the keys callers pass +// in `overrides`, and turned into anchored regexes here. +function asMatcher(pattern) { + const path = pattern.replace(/^\*\*/, ''); + return new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:[?#]|$)'); +} + // ── AI mock — intercepts generation endpoints ────────────── // Canned response shape matches what each route's frontend expects. // Override per-test by passing {pattern: responseFn} in overrides. @@ -111,7 +126,7 @@ async function mockAI(page, overrides = {}) { for (const { pattern, response } of routes) { const override = overrides[pattern]; - await page.route(pattern, async route => { + await page.route(asMatcher(pattern), async route => { const resp = typeof override === 'function' ? await override(route.request()) : (override || response); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(resp) }); }); diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js index 9fafb833..02d16a79 100644 --- a/e2e/playwright.config.js +++ b/e2e/playwright.config.js @@ -28,6 +28,12 @@ module.exports = defineConfig({ reporter: [['list']], use: { baseURL: process.env.BASE_URL || 'http://127.0.0.1:3553', + // The app registers a service worker that answers every /api/ request with + // its own fetch(). A request made inside a service worker never reaches + // page.route, so mockAI could not intercept anything while one was running + // and the tests called the real model. Blocking registration puts the + // requests back in the page, where the mocks can see them. + serviceWorkers: 'block', trace: 'retain-on-failure', screenshot: 'only-on-failure', actionTimeout: 5_000, diff --git a/e2e/tests/settings-faq-dictation.spec.js b/e2e/tests/settings-faq-dictation.spec.js index e86366c9..fe216d04 100644 --- a/e2e/tests/settings-faq-dictation.spec.js +++ b/e2e/tests/settings-faq-dictation.spec.js @@ -5,6 +5,12 @@ const { test, expect, E2E_BASE, mockAI } = require('../fixtures'); +// Settings and FAQ are not on the tab rail. They live in the account-card menu +// alongside Admin, and this helper used to click button.tab-btn[data-tab=…] for +// them, which simply timed out — the cause of ten of these failures. Dictation +// really is a rail tab, so both routes are needed. +const ACCOUNT_MENU = ['settings', 'faq']; + async function openTab(page, name) { await page.goto(E2E_BASE + '/'); await page.waitForSelector('button.tab-btn', { timeout: 15000 }); @@ -12,7 +18,12 @@ async function openTab(page, name) { if (vp && vp.width <= 768) { await page.click('#btn-menu-toggle').catch(() => {}); } - await page.click(`button.tab-btn[data-tab="${name}"]`); + if (ACCOUNT_MENU.includes(name)) { + await page.locator('.account-card-btn').first().click(); + await page.locator(`[data-account-tab="${name}"]`).first().click(); + } else { + await page.click(`button.tab-btn[data-tab="${name}"]`); + } await page.waitForFunction((t) => { const el = document.getElementById(t + '-tab'); return el && el.classList.contains('active') && el.innerHTML.trim().length > 100; diff --git a/public/components/my-resources.html b/public/components/my-resources.html index 6997e59b..5ac903c3 100644 --- a/public/components/my-resources.html +++ b/public/components/my-resources.html @@ -1,9 +1,14 @@

My Resources

- Private to you + Only you can see these
+

+ Build a teaching deck or handout on any topic — grounded in the clinical library, + the literature, or both — then revise it below and download it as PowerPoint, + Word or PDF. +

@@ -27,16 +32,6 @@
-
- Clinical library -
- -
-
- -