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');
+ }
});