fix: separate Scribe/Clinical/Learning prompt sections; drop model classification entirely with scrub migration
This commit is contained in:
parent
13fca7887d
commit
e7599128ec
4 changed files with 124 additions and 63 deletions
21
migrations/1780000000000_drop-model-classification.js
Normal file
21
migrations/1780000000000_drop-model-classification.js
Normal file
|
|
@ -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.
|
||||
};
|
||||
|
|
@ -161,6 +161,16 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: AI Scribe Prompts ─────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-robot"></i> AI Scribe</h3>
|
||||
</div>
|
||||
<div style="padding:16px;">
|
||||
<div id="cms-scribe-prompts">Loading Scribe prompts...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: SMTP Email Server ─────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
|
|
@ -307,9 +317,13 @@
|
|||
<strong class="admin-row-label">Conversation input budget</strong>
|
||||
<p id="assistant-conversation-budget" role="status" style="margin:0;font-size:13px;color:var(--g600);">Loading server budget metadata...</p>
|
||||
</div>
|
||||
<section aria-labelledby="prompts-heading">
|
||||
<h4 id="prompts-heading">Prompts</h4>
|
||||
<div id="cms-prompt-editor">Loading prompts...</div>
|
||||
<section aria-labelledby="clinical-prompts-heading">
|
||||
<h4 id="clinical-prompts-heading">Clinical Assistant prompts</h4>
|
||||
<div id="cms-clinical-prompts">Loading clinical prompts...</div>
|
||||
</section>
|
||||
<section aria-labelledby="learning-prompts-heading">
|
||||
<h4 id="learning-prompts-heading">Learning prompts</h4>
|
||||
<div id="cms-learning-prompts">Loading Learning prompts...</div>
|
||||
</section>
|
||||
<section id="workflow-image-settings" aria-label="Workflow image settings"></section>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = '<option value="7">Existing snapshot</option>';
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue