diff --git a/migrations/1780000000000_drop-model-classification.js b/migrations/1780000000000_drop-model-classification.js new file mode 100644 index 0000000..d897a28 --- /dev/null +++ b/migrations/1780000000000_drop-model-classification.js @@ -0,0 +1,21 @@ +// Model classification (cost/tag/category) is discontinued: scrub stored custom +// model JSON and refuse re-adding those keys at the application layer. +exports.up = pgm => { + pgm.sql(` + UPDATE app_settings + SET value = ( + SELECT jsonb_agg(jsonb_build_object('id', item->>'id', 'name', item->>'name'))::text + FROM jsonb_array_elements(value::jsonb) AS item + WHERE item ? 'id' AND item ? 'name' + ) + WHERE key = 'models.custom' + AND value IS NOT NULL + AND value <> '' + AND value::jsonb IS NOT NULL + AND value::jsonb <> '[]'::jsonb; + `); +}; + +exports.down = pgm => { + // No historical classification data remains to restore; nothing to do. +}; diff --git a/public/components/admin.html b/public/components/admin.html index dce3fb2..df7600d 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -161,6 +161,16 @@ + +
+
+

AI Scribe

+
+
+
Loading Scribe prompts...
+
+
+
@@ -307,9 +317,13 @@ Conversation input budget

Loading server budget metadata...

-
-

Prompts

-
Loading prompts...
+
+

Clinical Assistant prompts

+
Loading clinical prompts...
+
+
+

Learning prompts

+
Loading Learning prompts...
diff --git a/public/js/admin.js b/public/js/admin.js index 95c8d0e..867bd52 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -559,23 +559,33 @@ function adminTabActive() { // General settings reloads must not replace any prompt drafts. if (promptsLoaded || promptsLoading) return; promptsLoading = true; - var container = document.getElementById('cms-prompt-editor'); + var groups = [ + ['scribe', document.getElementById('cms-scribe-prompts'), ['scribe']], + ['clinical', document.getElementById('cms-clinical-prompts'), ['clinical-text', 'clinical-image']], + ['learning', document.getElementById('cms-learning-prompts'), ['learning-image']] + ]; try { var data = await promptRequest('/api/admin/config/prompts'); - if (container) { - container.replaceChildren(); - var prompts = (data.prompts || []).filter(function(p) { return p.editable === true; }); - container.appendChild(createPromptFamilyEditor('all', prompts)); - } + groups.forEach(function(group) { + if (!group[1]) return; + group[1].replaceChildren(); + var prompts = (data.prompts || []).filter(function(p) { return p.editable === true && group[2].indexOf(p.family) !== -1; }); + if (!prompts.length) { + group[1].textContent = 'No editable prompts available in this section.'; + return; + } + group[1].appendChild(createPromptFamilyEditor(group[0], prompts)); + }); promptsLoaded = true; } catch (error) { - if (container) { - container.textContent = 'Could not load prompts: ' + error.message + ' '; + groups.forEach(function(group) { + if (!group[1]) return; + group[1].textContent = 'Could not load prompts: ' + error.message + ' '; var retry = document.createElement('button'); retry.type = 'button'; retry.className = 'btn-sm btn-ghost'; retry.textContent = 'Retry loading prompts'; retry.onclick = loadPromptList; - container.appendChild(retry); - } + group[1].appendChild(retry); + }); } finally { promptsLoading = false; } } diff --git a/test/frontend-prompt-env.test.js b/test/frontend-prompt-env.test.js index 4656194..ad087e2 100644 --- a/test/frontend-prompt-env.test.js +++ b/test/frontend-prompt-env.test.js @@ -50,7 +50,9 @@ async function browser(t, module, handler) { await tick(); return { window, document: window.document, calls, toasts }; } -const familyOf = ui => ui.document.getElementById('cms-prompt-editor'); +const sectionOf = (ui, name) => ui.document.getElementById(name === 'scribe' ? 'cms-scribe-prompts' : name === 'clinical' ? 'cms-clinical-prompts' : 'cms-learning-prompts'); +const sectionForPrompt = (ui, p) => sectionOf(ui, p.family === 'scribe' ? 'scribe' : p.family === 'learning-image' ? 'learning' : 'clinical'); +const familyOf = ui => ui.document.getElementById('cms-scribe-prompts'); const selectOf = family => family.querySelector('.prompt-select'); const draftOf = family => family.querySelector('.prompt-draft'); const statusOf = family => family.querySelector('.prompt-status'); @@ -63,18 +65,23 @@ async function choosePrompt(family, dbKey) { } async function clickAction(family, name) { actionOf(family, name).click(); await tick(); } -test('single prompt editor lists the whole catalogue, displays inert exact text, and saves canonical dbKey + expectedRevision', async t => { +test('three prompt sections keep Scribe, Clinical and Learning separate with inert exact text and canonical saves', async t => { const ui = await browser(t, 'admin', (url, options) => { if (options.method === 'PUT') return json({ success: true, value: JSON.parse(options.body).value, revision: 11 }); }); - const editor = familyOf(ui); - assert.equal(selectOf(editor).options.length, 32, 'all families in one dropdown'); + const scribe = sectionOf(ui, 'scribe'); + const clinical = sectionOf(ui, 'clinical'); + const learning = sectionOf(ui, 'learning'); + assert.equal(selectOf(scribe).options.length, 29, 'Scribe stays as its own section'); + assert.equal(selectOf(clinical).options.length, 2, 'Clinical TEXT and IMAGE share the Clinical section'); + assert.equal(selectOf(learning).options.length, 1, 'Learning has its own section'); assert.equal(ui.document.querySelector('script, img, [onerror]'), null); - assert.equal(selectOf(editor).value, catalogue[0].dbKey, 'first prompt selected by default'); - assert.equal(selectOf(editor).options[0].textContent, 'SCRIBE_0'); - assert.equal(draftOf(editor).value, unsafe); - for (const name of ['save', 'history', 'view', 'restore', 'reset', 'baseline']) assert.ok(actionOf(editor, name)); + assert.equal(selectOf(scribe).value, catalogue[0].dbKey, 'first prompt selected by default'); + assert.equal(selectOf(scribe).options[0].textContent, 'SCRIBE_0'); + assert.equal(draftOf(scribe).value, unsafe); + for (const name of ['save', 'history', 'view', 'restore', 'reset', 'baseline']) assert.ok(actionOf(scribe, name)); for (const p of [catalogue[0], catalogue[1], ...catalogue.slice(-2)]) { + const editor = sectionForPrompt(ui, p); await choosePrompt(editor, p.dbKey); assert.equal(draftOf(editor).value, unsafe); draftOf(editor).value = '\n' + unsafe + '\n'; @@ -85,8 +92,8 @@ test('single prompt editor lists the whole catalogue, displays inert exact text, assert.deepEqual(call.body, { value: '\n' + unsafe + '\n', expectedRevision: p.revision }); assert.match(statusOf(editor).textContent, /Saved revision 11/); } - await choosePrompt(editor, catalogue[2].dbKey); - assert.equal(draftOf(editor).value, unsafe, 'other prompts unchanged'); + await choosePrompt(scribe, catalogue[2].dbKey); + assert.equal(draftOf(scribe).value, unsafe, 'other prompts unchanged'); }); test('history/view/restore/reset use exact per-key APIs for every family and never replace other drafts', async t => { @@ -97,13 +104,13 @@ test('history/view/restore/reset use exact per-key APIs for every family and nev if (url.endsWith('/restore')) return json({ success: true, value: unsafe, revision: 11 }); if (url.endsWith('/reset')) return json({ success: true, value: 'new shipped default', revision: 12 }); }); - const editor = familyOf(ui); - await choosePrompt(editor, catalogue[1].dbKey); - draftOf(editor).value = 'Unrelated unsaved Scribe draft'; - await choosePrompt(editor, catalogue[0].dbKey); + const scribe = sectionOf(ui, 'scribe'); + await choosePrompt(scribe, catalogue[1].dbKey); + draftOf(scribe).value = 'Unrelated unsaved Scribe draft'; + await choosePrompt(scribe, catalogue[0].dbKey); for (const p of [catalogue[0], ...catalogue.slice(-2)]) { - await choosePrompt(editor, p.dbKey); - const f = editor; + const f = sectionForPrompt(ui, p); + await choosePrompt(f, p.dbKey); const base = '/api/admin/config/prompts/' + p.dbKey; draftOf(f).value = 'Unsaved clinical/Scribe edits'; await clickAction(f, 'history'); @@ -126,8 +133,8 @@ test('history/view/restore/reset use exact per-key APIs for every family and nev assert.deepEqual(ui.calls.at(-1).body, { expectedRevision: 11 }); assert.equal(draftOf(f).value, 'new shipped default'); } - await choosePrompt(editor, catalogue[1].dbKey); - assert.equal(draftOf(editor).value, 'Unrelated unsaved Scribe draft', 'switch never discards another prompt\'s draft'); + await choosePrompt(scribe, catalogue[1].dbKey); + assert.equal(draftOf(scribe).value, 'Unrelated unsaved Scribe draft', 'switch never discards another prompt\'s draft'); }); test('409s preserve drafts; explicit review/rebase resolves conflicts without an automatic overwrite', async t => { @@ -140,7 +147,7 @@ test('409s preserve drafts; explicit review/rebase resolves conflicts without an return json({ success: true, value: JSON.parse(options.body).value, revision: 15 }); } }); - const f = familyOf(ui); + const f = sectionOf(ui, 'clinical'); await choosePrompt(f, 'clinical_assistant.system_behavior'); draftOf(f).value = 'Keep this draft'; await clickAction(f, 'save'); @@ -171,12 +178,12 @@ test('failed loads can retry; transport failures and in-flight saves preserve ne return new Promise(resolve => { release = () => resolve(json({ success: true, revision: 11, value: JSON.parse(options.body).value })); }); } }); - const editor = familyOf(ui); - assert.match(editor.textContent, /Unavailable catalogue/); - assert.ok(editor.querySelector('button'), 'retry control stays visible'); + const scribe = sectionOf(ui, 'scribe'); + assert.match(scribe.textContent, /Unavailable catalogue/); + assert.ok(scribe.querySelector('button'), 'retry control stays visible'); catalogueFailure = false; - editor.querySelector('button').click(); await tick(); - const f = editor; + scribe.querySelector('button').click(); await tick(); + const f = sectionOf(ui, 'clinical'); await choosePrompt(f, 'clinical_assistant.image_behavior'); draftOf(f).value = 'Preserved draft'; await clickAction(f, 'history'); @@ -203,7 +210,7 @@ test('empty history and failed revision viewing leave drafts intact and restore if (url.endsWith('/history?limit=100')) return json({ success: true, revision: empty ? 0 : 4, revisions: empty ? [] : [{ id: 4, createdAt: 'now' }] }); if (url.endsWith('/revisions/4')) return json({ error: 'Revision unavailable' }, 404); }); - const f = familyOf(ui); + const f = sectionOf(ui, 'scribe'); await choosePrompt(f, catalogue[1].dbKey); draftOf(f).value = 'Keep even when no history is available'; await clickAction(f, 'history'); @@ -458,7 +465,7 @@ test('assistant settings retries leave global prompt drafts/history and starter const ui = await browser(t, 'admin', url => { if (url === '/api/admin/config') return available ? json(assistantConfig()) : json({ error: 'Request failed' }, 503); }); - const f = familyOf(ui); + const f = sectionOf(ui, 'scribe'); draftOf(f).value = 'Unsaved global prompt'; f.querySelector('.prompt-revision-text').value = 'Previously viewed revision'; setting(ui, 'prompt-pool-snapshots').innerHTML = ''; @@ -499,41 +506,50 @@ test('admin tab already active at module init triggers loaders exactly once; gua assert.equal(calls.filter(c => c.url === '/api/admin/config/prompts').length, 1, 'catalogue loader catches up exactly once at init'); assert.equal(calls.filter(c => c.url === '/api/admin/config').length, 2, 'CMS config and assistant settings each load once at init'); assert.equal(calls.filter(c => c.url === '/api/admin/config/models').length, 1, 'model list catches up at init'); - assert.equal(window.document.getElementById('cms-prompt-editor').querySelectorAll('.prompt-select option').length, 32); + assert.equal(window.document.getElementById('cms-scribe-prompts').querySelectorAll('.prompt-select option').length, 29); window.document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'admin' } })); await tick(); assert.equal(calls.filter(c => c.url === '/api/admin/config/prompts').length, 1, 'revisit never double-fires the catalogue loader'); assert.equal(calls.filter(c => c.url === '/api/admin/config').length, 2, 'guarded assistant/CMS loaders never double-fire'); }); -test('switching prompt keeps every unsaved draft in memory', async t => { +test('switching prompts within each section keeps every unsaved draft in memory', async t => { const ui = await browser(t, 'admin'); - const editor = familyOf(ui); - await choosePrompt(editor, catalogue[0].dbKey); - draftOf(editor).value = 'Scribe draft A'; - await choosePrompt(editor, catalogue[1].dbKey); - assert.equal(draftOf(editor).value, unsafe); - draftOf(editor).value = 'Scribe draft B'; - await choosePrompt(editor, 'clinical_assistant.system_behavior'); - draftOf(editor).value = 'Text draft'; - await choosePrompt(editor, 'clinical_assistant.image_behavior'); - draftOf(editor).value = 'Image draft'; - await choosePrompt(editor, catalogue[0].dbKey); - assert.equal(draftOf(editor).value, 'Scribe draft A'); - await choosePrompt(editor, catalogue[1].dbKey); - assert.equal(draftOf(editor).value, 'Scribe draft B'); - await choosePrompt(editor, 'clinical_assistant.system_behavior'); - assert.equal(draftOf(editor).value, 'Text draft'); - await choosePrompt(editor, 'clinical_assistant.image_behavior'); - assert.equal(draftOf(editor).value, 'Image draft'); + const scribe = sectionOf(ui, 'scribe'); + const clinical = sectionOf(ui, 'clinical'); + const learning = sectionOf(ui, 'learning'); + await choosePrompt(scribe, catalogue[0].dbKey); + draftOf(scribe).value = 'Scribe draft A'; + await choosePrompt(scribe, catalogue[1].dbKey); + assert.equal(draftOf(scribe).value, unsafe); + draftOf(scribe).value = 'Scribe draft B'; + await choosePrompt(clinical, 'clinical_assistant.system_behavior'); + draftOf(clinical).value = 'Text draft'; + await choosePrompt(clinical, 'clinical_assistant.image_behavior'); + assert.equal(draftOf(clinical).value, unsafe); + draftOf(clinical).value = 'Image draft'; + await choosePrompt(learning, 'learning_hub.image_behavior'); + draftOf(learning).value = 'Learning draft'; + await choosePrompt(scribe, catalogue[0].dbKey); + assert.equal(draftOf(scribe).value, 'Scribe draft A'); + await choosePrompt(scribe, catalogue[1].dbKey); + assert.equal(draftOf(scribe).value, 'Scribe draft B'); + await choosePrompt(clinical, 'clinical_assistant.system_behavior'); + assert.equal(draftOf(clinical).value, 'Text draft'); + await choosePrompt(clinical, 'clinical_assistant.image_behavior'); + assert.equal(draftOf(clinical).value, 'Image draft'); + await choosePrompt(learning, 'learning_hub.image_behavior'); + assert.equal(draftOf(learning).value, 'Learning draft'); }); test('catalogue loading failure always reaches a visible retry state, never an eternal spinner', async t => { const ui = await browser(t, 'admin', url => { if (url === '/api/admin/config/prompts') return json({ error: 'Catalogue offline' }, 503); }); - const f = familyOf(ui); - assert.match(f.textContent, /Catalogue offline/); - assert.ok(f.querySelector('button'), 'retry button rendered'); - assert.doesNotMatch(f.textContent, /Loading/, 'no eternal spinner text remains'); + for (const name of ['scribe', 'clinical', 'learning']) { + const f = sectionOf(ui, name); + assert.match(f.textContent, /Catalogue offline/); + assert.ok(f.querySelector('button'), 'retry button rendered'); + assert.doesNotMatch(f.textContent, /Loading/, 'no eternal spinner text remains'); + } });