fix: separate Scribe/Clinical/Learning prompt sections; drop model classification entirely with scrub migration
All checks were successful
Forgejo Android APK / Root app tests (push) Successful in 26s
Forgejo Android APK / Build signed APK (push) Successful in 1m58s

This commit is contained in:
Daniel 2026-09-07 18:22:51 +02:00
parent 13fca7887d
commit e7599128ec
4 changed files with 124 additions and 63 deletions

View 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.
};

View file

@ -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>

View file

@ -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; }
}

View file

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