fix: the image settings card can save again; Learning Hub is out of the admin
"Not all of it was saved: Workflow not found" on every press of Save availability. The card sent image settings for three workflows, and the server has only had two since Learning Hub was removed — the DB constraint allows clinical_assistant and my_resources and nothing else. One rejection failed the whole save, so the two settings that were valid looked unsaved as well. The frontend was the only place that still believed in it. Also gone: the learning_hub.image_behavior prompt, its Learning prompts section in the admin — which held that one prompt and nothing else — and the theme's card tints, which never applied. While there: My Resources had a Model dropdown labelled "set per request", permanently disabled and permanently empty. A control that can never do anything reads as broken rather than as not applicable, so it is now a sentence saying where the model is actually chosen. Its fallbacks stay — those apply to whichever model the request picked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
3a40ff9b6d
commit
c14fa25c3f
8 changed files with 98 additions and 47 deletions
|
|
@ -1,40 +1,75 @@
|
|||
{
|
||||
"_comment": "A theme is a palette and a typeface, nothing more. Every slide type already draws from these five names, so adding one here restyles the whole deck with no change to any slide builder. Colours are hex without a leading #, the form python-pptx wants.",
|
||||
"_comment": "A theme is a palette and a typeface, nothing more. Every slide type draws from these names, so adding one here restyles the whole deck with no change to any slide builder. tint/tint_alt are the two card fills a comparison uses, and accent_alt is the second hue: the edge of the second card and of a callout. Before these existed both were hardcoded blue and amber, so those cards stayed the same colour under every theme. Colours are hex without a leading #, the form python-pptx wants.",
|
||||
"themes": [
|
||||
{
|
||||
"id": "clinical-blue",
|
||||
"name": "Clinical Blue",
|
||||
"description": "The default. Calm, high-contrast, reads well on a projector.",
|
||||
"accent": "2563EB", "ink": "1F2937", "muted": "4B5563",
|
||||
"rule": "E5E7EB", "paper": "FFFFFF", "font": "Calibri"
|
||||
"accent": "2563EB",
|
||||
"ink": "1F2937",
|
||||
"muted": "4B5563",
|
||||
"rule": "E5E7EB",
|
||||
"paper": "FFFFFF",
|
||||
"font": "Calibri",
|
||||
"tint": "EFF6FF",
|
||||
"tint_alt": "FEF3C7",
|
||||
"accent_alt": "D97706"
|
||||
},
|
||||
{
|
||||
"id": "teaching-amber",
|
||||
"name": "Teaching Amber",
|
||||
"description": "Warmer and less clinical. Good for sessions with parents or students.",
|
||||
"accent": "D97706", "ink": "1C1917", "muted": "57534E",
|
||||
"rule": "EDE9E4", "paper": "FFFFFF", "font": "Calibri"
|
||||
"accent": "D97706",
|
||||
"ink": "1C1917",
|
||||
"muted": "57534E",
|
||||
"rule": "EDE9E4",
|
||||
"paper": "FFFFFF",
|
||||
"font": "Calibri",
|
||||
"tint": "FEF3C7",
|
||||
"tint_alt": "EFF6FF",
|
||||
"accent_alt": "2563EB"
|
||||
},
|
||||
{
|
||||
"id": "ward-teal",
|
||||
"name": "Ward Teal",
|
||||
"description": "Quieter than blue, still clearly clinical.",
|
||||
"accent": "0F766E", "ink": "134E4A", "muted": "4B5563",
|
||||
"rule": "E3EDEB", "paper": "FFFFFF", "font": "Calibri"
|
||||
"accent": "0F766E",
|
||||
"ink": "134E4A",
|
||||
"muted": "4B5563",
|
||||
"rule": "E3EDEB",
|
||||
"paper": "FFFFFF",
|
||||
"font": "Calibri",
|
||||
"tint": "CCFBF1",
|
||||
"tint_alt": "FEF3C7",
|
||||
"accent_alt": "D97706"
|
||||
},
|
||||
{
|
||||
"id": "slate",
|
||||
"name": "Slate",
|
||||
"description": "Almost monochrome. Lets figures and tables carry the colour.",
|
||||
"accent": "475569", "ink": "0F172A", "muted": "64748B",
|
||||
"rule": "E2E8F0", "paper": "FFFFFF", "font": "Calibri"
|
||||
"accent": "475569",
|
||||
"ink": "0F172A",
|
||||
"muted": "64748B",
|
||||
"rule": "E2E8F0",
|
||||
"paper": "FFFFFF",
|
||||
"font": "Calibri",
|
||||
"tint": "F1F5F9",
|
||||
"tint_alt": "E2E8F0",
|
||||
"accent_alt": "64748B"
|
||||
},
|
||||
{
|
||||
"id": "high-contrast",
|
||||
"name": "High Contrast",
|
||||
"description": "For a bright room or a poor projector. Heavier ink, stronger rules.",
|
||||
"accent": "B91C1C", "ink": "000000", "muted": "27272A",
|
||||
"rule": "A1A1AA", "paper": "FFFFFF", "font": "Calibri"
|
||||
"accent": "B91C1C",
|
||||
"ink": "000000",
|
||||
"muted": "27272A",
|
||||
"rule": "A1A1AA",
|
||||
"paper": "FFFFFF",
|
||||
"font": "Calibri",
|
||||
"tint": "FEE2E2",
|
||||
"tint_alt": "F3F4F6",
|
||||
"accent_alt": "111827"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -497,10 +497,6 @@
|
|||
<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>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -651,8 +651,7 @@ function adminTabActive() {
|
|||
promptsLoading = true;
|
||||
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']]
|
||||
['clinical', document.getElementById('cms-clinical-prompts'), ['clinical-text', 'clinical-image']]
|
||||
];
|
||||
try {
|
||||
var data = await promptRequest('/api/admin/config/prompts');
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { imageJson } from '../generatedImages.js';
|
|||
|
||||
const WORKFLOWS = [
|
||||
{ key: 'clinical_assistant', label: 'Clinical Assistant' },
|
||||
{ key: 'learning_hub', label: 'Learning Hub' },
|
||||
{ key: 'my_resources', label: 'My Resources' }
|
||||
];
|
||||
|
||||
|
|
@ -51,10 +50,22 @@ async function load() {
|
|||
group.appendChild(legend);
|
||||
|
||||
// My Resources picks its model per request from the roster, so only the
|
||||
// Learning Hub and the Assistant name a primary here.
|
||||
// Only the Assistant names a primary here. My Resources takes its model
|
||||
// from the request that asks for the picture, so a disabled, permanently
|
||||
// empty dropdown labelled "set per request" was a control that could
|
||||
// never do anything — it read as broken rather than as not applicable.
|
||||
// Its fallbacks still belong here: those are what runs when the chosen
|
||||
// model fails, whoever chose it.
|
||||
const primary = makeSelect([saved.model], discovered);
|
||||
primary.disabled = workflow.key === 'my_resources';
|
||||
group.appendChild(row(workflow.key === 'my_resources' ? 'Model (set per request)' : 'Model', primary));
|
||||
if (workflow.key === 'my_resources') {
|
||||
const note = document.createElement('p');
|
||||
note.style.cssText = 'margin:2px 0 8px;font-size:12px;color:var(--g500);';
|
||||
note.textContent = 'The model is chosen per request, where the deck is generated. ' +
|
||||
'The fallbacks below apply to whichever model that request picked.';
|
||||
group.appendChild(note);
|
||||
} else {
|
||||
group.appendChild(row('Model', primary));
|
||||
}
|
||||
|
||||
const fallbacks = [];
|
||||
for (let i = 0; i < maxModels - 1; i++) {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,14 @@ MUTED = RGBColor(0x4B, 0x55, 0x63)
|
|||
ACCENT = RGBColor(0x25, 0x63, 0xEB)
|
||||
RULE = RGBColor(0xE5, 0xE7, 0xEB)
|
||||
PAPER = RGBColor(0xFF, 0xFF, 0xFF)
|
||||
# The two card fills a comparison uses, and the second hue that edges the
|
||||
# second card and a callout. These were written inline at both call sites, so a
|
||||
# comparison stayed blue-and-amber under every theme and only the headings
|
||||
# moved. Defaults match what was hardcoded, so a deck with no theme is
|
||||
# unchanged.
|
||||
TINT = RGBColor(0xEF, 0xF6, 0xFF)
|
||||
TINT_ALT = RGBColor(0xFE, 0xF3, 0xC7)
|
||||
ACCENT_ALT = RGBColor(0xD9, 0x77, 0x06)
|
||||
|
||||
FONT = "Calibri"
|
||||
|
||||
|
|
@ -96,11 +104,16 @@ def apply_theme(theme_id):
|
|||
for theme in load_themes():
|
||||
if theme.get("id") != theme_id:
|
||||
continue
|
||||
global INK, MUTED, ACCENT, RULE, PAPER, FONT
|
||||
global INK, MUTED, ACCENT, RULE, PAPER, FONT, TINT, TINT_ALT, ACCENT_ALT
|
||||
try:
|
||||
INK = _rgb(theme["ink"])
|
||||
MUTED = _rgb(theme["muted"])
|
||||
ACCENT = _rgb(theme["accent"])
|
||||
# .get, not [...]: a theme written before these existed should
|
||||
# still apply its other colours rather than fall back wholesale.
|
||||
TINT = _rgb(theme.get("tint") or "EFF6FF")
|
||||
TINT_ALT = _rgb(theme.get("tint_alt") or "FEF3C7")
|
||||
ACCENT_ALT = _rgb(theme.get("accent_alt") or "D97706")
|
||||
RULE = _rgb(theme["rule"])
|
||||
PAPER = _rgb(theme["paper"])
|
||||
except Exception:
|
||||
|
|
@ -391,8 +404,8 @@ def slide_compare(prs, spec):
|
|||
gutter = Emu(365760)
|
||||
col_w = Emu(int((BODY_W - gutter) / 2))
|
||||
columns = (spec.get("columns") or [])[:2]
|
||||
tints = [RGBColor(0xEF, 0xF6, 0xFF), RGBColor(0xFE, 0xF3, 0xC7)]
|
||||
edges = [ACCENT, RGBColor(0xD9, 0x77, 0x06)]
|
||||
tints = [TINT, TINT_ALT]
|
||||
edges = [ACCENT, ACCENT_ALT]
|
||||
sizes = [_fit_size(c.get("bullets") or [], 0.44, int(BODY_H) - int(Emu(548640)))
|
||||
for c in columns] or [BULLET_SIZES[0]]
|
||||
size = min(sizes)
|
||||
|
|
@ -424,8 +437,8 @@ def slide_callout(prs, spec):
|
|||
_heading(slide, spec.get("heading") or "")
|
||||
card = slide.shapes.add_shape(5, MARGIN, BODY_TOP, BODY_W, Emu(int(BODY_H * 0.62)))
|
||||
card.fill.solid()
|
||||
card.fill.fore_color.rgb = RGBColor(0xFE, 0xF3, 0xC7)
|
||||
card.line.color.rgb = RGBColor(0xD9, 0x77, 0x06)
|
||||
card.fill.fore_color.rgb = TINT_ALT
|
||||
card.line.color.rgb = ACCENT_ALT
|
||||
card.line.width = Pt(1.5)
|
||||
card.shadow.inherit = False
|
||||
card.text_frame.text = ""
|
||||
|
|
|
|||
|
|
@ -40,10 +40,7 @@ const entries = PROMPTS.getAllPrompts().map(({ key }) => ({
|
|||
usedBy: ['Clinical Assistant chat', 'Clinical Assistant streaming chat'], editable: true },
|
||||
{ key: 'clinical_assistant.image_behavior', dbKey: 'clinical_assistant.image_behavior', family: 'clinical-image',
|
||||
purpose: 'Poster instruction appended to image input, before fixed portrait/landscape layout suffixes',
|
||||
usedBy: ['Clinical Assistant image', 'Clinical Assistant image job', 'Clinical Assistant generate_image tool'], editable: true },
|
||||
{ key: 'learning_hub.image_behavior', dbKey: 'learning_hub.image_behavior', family: 'learning-image',
|
||||
purpose: 'Learning Hub authoring image instructions, separately versioned from Clinical Assistant',
|
||||
usedBy: ['Learning Hub generate_image tool', 'Learning Hub authoring images'], editable: true }
|
||||
usedBy: ['Clinical Assistant image', 'Clinical Assistant image job', 'Clinical Assistant generate_image tool'], editable: true }
|
||||
]);
|
||||
entries.forEach(entry => { Object.freeze(entry.usedBy); Object.freeze(entry); });
|
||||
Object.freeze(entries);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ const catalogue = [
|
|||
...Array.from({ length: 29 }, (_, i) => ({ key: 'SCRIBE_' + i, dbKey: 'prompt.SCRIBE_' + i, family: 'scribe', revision: i ? 0 : 10 })),
|
||||
{ key: 'clinical_assistant.system_behavior', dbKey: 'clinical_assistant.system_behavior', family: 'clinical-text', revision: 10 },
|
||||
{ key: 'clinical_assistant.image_behavior', dbKey: 'clinical_assistant.image_behavior', family: 'clinical-image', revision: 10 },
|
||||
{ key: 'learning_hub.image_behavior', dbKey: 'learning_hub.image_behavior', family: 'learning-image', revision: 10 }
|
||||
].map(p => ({ ...p, value: unsafe, purpose: unsafe, usedBy: ['Synthetic runtime operation', unsafe], editable: true }));
|
||||
let moduleId = 0;
|
||||
|
||||
|
|
@ -54,8 +53,8 @@ async function browser(t, module, handler) {
|
|||
await tick();
|
||||
return { window, document: window.document, calls, toasts, context: values };
|
||||
}
|
||||
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 sectionOf = (ui, name) => ui.document.getElementById(name === 'scribe' ? 'cms-scribe-prompts' : 'cms-clinical-prompts');
|
||||
const sectionForPrompt = (ui, p) => sectionOf(ui, p.family === 'scribe' ? 'scribe' : 'clinical');
|
||||
const familyOf = ui => ui.document.getElementById('cms-scribe-prompts');
|
||||
const selectOf = family => family.querySelector('.prompt-select');
|
||||
const draftOf = family => family.querySelector('.prompt-draft');
|
||||
|
|
@ -75,10 +74,11 @@ test('three prompt sections keep Scribe, Clinical and Learning separate with ine
|
|||
});
|
||||
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');
|
||||
// Two sections, not three. The Learning section held exactly one prompt —
|
||||
// learning_hub.image_behavior — and that feature is gone.
|
||||
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.getElementById('cms-learning-prompts'), null, 'the Learning section is gone');
|
||||
assert.equal(ui.document.querySelector('script, img, [onerror]'), null);
|
||||
assert.equal(selectOf(scribe).value, catalogue[0].dbKey, 'first prompt selected by default');
|
||||
assert.equal(selectOf(scribe).options[0].textContent, 'SCRIBE_0');
|
||||
|
|
@ -392,7 +392,6 @@ test('image-model dropdowns keep saved selections through discovery failures and
|
|||
if (url === '/api/models') throw Error('chat discovery offline');
|
||||
if (url === '/api/admin/image-settings') return json({ success: true, maxModels: 3, workflows: {
|
||||
clinical_assistant: { model: 'saved-image', fallbacks: ['saved-backup'], budget: 32000 },
|
||||
learning_hub: { model: 'saved-image', fallbacks: [], budget: 32000 },
|
||||
my_resources: { model: '', fallbacks: [], budget: 32000 } } });
|
||||
if (url.endsWith('/image-models/discover')) return new Promise((resolve, reject) => { pending.push({ resolve, reject }); });
|
||||
});
|
||||
|
|
@ -414,11 +413,14 @@ test('image-model dropdowns keep saved selections through discovery failures and
|
|||
// One await per workflow, so the queue needs draining more than once.
|
||||
for (let i = 0; i < 6; i++) await tick();
|
||||
const puts = () => ui.calls.filter(c => c.options.method === 'PUT' && c.url.includes('/api/admin/image-settings/'));
|
||||
assert.equal(puts().length, 3, 'every workflow is saved, My Resources included');
|
||||
// Two, not three. A learning_hub entry used to be sent as well; the server
|
||||
// answers "Workflow not found" for it, and that one rejection failed the
|
||||
// whole save — the card reported "Not all of it was saved" every time.
|
||||
assert.equal(puts().length, 2, 'every live workflow is saved, My Resources included');
|
||||
assert.equal(puts().find(c => c.url.endsWith('learning_hub')), undefined,
|
||||
'Learning Hub is gone; saving to it is what broke the card');
|
||||
assert.deepEqual(puts().find(c => c.url.endsWith('clinical_assistant')).body,
|
||||
{ model: 'saved-image', budget: 32000, fallbacks: ['saved-backup'] });
|
||||
assert.deepEqual(puts().find(c => c.url.endsWith('learning_hub')).body,
|
||||
{ model: 'saved-image', budget: 32000, fallbacks: [] });
|
||||
// My Resources chooses its model per request, so the form must not send one.
|
||||
assert.deepEqual(puts().find(c => c.url.endsWith('my_resources')).body,
|
||||
{ budget: 32000, fallbacks: [] });
|
||||
|
|
@ -480,7 +482,6 @@ test('switching prompts within each section keeps every unsaved draft in memory'
|
|||
const ui = await browser(t, 'admin');
|
||||
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);
|
||||
|
|
@ -491,8 +492,6 @@ test('switching prompts within each section keeps every unsaved draft in memory'
|
|||
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);
|
||||
|
|
@ -501,15 +500,13 @@ test('switching prompts within each section keeps every unsaved draft in memory'
|
|||
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);
|
||||
});
|
||||
for (const name of ['scribe', 'clinical', 'learning']) {
|
||||
for (const name of ['scribe', 'clinical']) {
|
||||
const f = sectionOf(ui, name);
|
||||
assert.match(f.textContent, /Catalogue offline/);
|
||||
assert.ok(f.querySelector('button'), 'retry button rendered');
|
||||
|
|
|
|||
|
|
@ -135,7 +135,8 @@ test('finite catalogue, actual admin gates, revision API, stale edit, cross-key
|
|||
for (const [method, url, body] of routes) for (const user of [0, 2]) assert.equal((await app.request(method, url, body, user)).status, user ? 403 : 401);
|
||||
assert.equal(svc.state.log.length, 0);
|
||||
const list = (await app.request('GET', '/config/prompts')).body.prompts;
|
||||
assert.equal(list.length, 32); assert.equal(list.filter(p => p.family === 'scribe').length, 29);
|
||||
// 31, down from 32: learning_hub.image_behavior went with the Learning Hub.
|
||||
assert.equal(list.length, 31); assert.equal(list.filter(p => p.family === 'scribe').length, 29);
|
||||
assert.equal(list.filter(p => p.family === 'clinical-text').length, 1); assert.equal(list.filter(p => p.family === 'clinical-image').length, 1);
|
||||
for (const prompt of list) { assert.equal(prompt.revision, 0); assert.equal(prompt.editable, true); assert.ok(prompt.purpose && prompt.usedBy.length && prompt.value); }
|
||||
for (const key of ['prompt.unknown', 'prompt.loadFromDb', 'prompt.updatePrompt', 'prompt.getAllPrompts', 'prompt.getDefaultPrompt', 'prompt.__proto__', 'prompt.smtp.pass']) {
|
||||
|
|
@ -305,7 +306,9 @@ test('migration owns finite append-only schema and emits reversible SQL without
|
|||
}
|
||||
const up = await dryRun('up'); const down = await dryRun('down');
|
||||
const keys = [...up[0].matchAll(/'(prompt\.[^']+|clinical_assistant\.[^']+)'/g)].map(match => match[1]);
|
||||
assert.deepEqual(keys.sort(), Array.from(services().catalog.entries.filter(entry => entry.dbKey !== 'learning_hub.image_behavior'), entry => entry.dbKey).sort());
|
||||
// Every catalogue entry, with nothing filtered out: the one exception used
|
||||
// to be learning_hub.image_behavior, which no longer exists.
|
||||
assert.deepEqual(keys.sort(), Array.from(services().catalog.entries, entry => entry.dbKey).sort());
|
||||
assert.match(up[0], /BEFORE UPDATE OR DELETE/); assert.match(up[0], /FOREIGN KEY \(prompt_key, restored_from\)/);
|
||||
assert.match(down[0], /DROP TABLE prompt_revisions/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue