pediatric-ai-scribe-v3/public/js/myResources.js
Daniel 07d1c541a0
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 23s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
fix: a deck the model fumbles once is asked for again, not abandoned
A presentation is generated as a typed deck — the model chooses each slide's
layout — and that deck is what scripts/render_pptx.py renders. When the reply
did not parse as a deck, the route fell straight back to asking for markdown,
and slideSpec.build() then inferred slides from it. Both paths go through
python-pptx, but the fallback's layout is guessed from heading and list
structure rather than chosen, so everything lands as title-and-bullets.

Measured on the stored library: since decks landed, 7 of 8 generations produced
one and 1 did not. Models are stochastic, so one unlucky reply was costing the
whole layout. It now asks a second time with the same prompt before giving up.

The fallback was also invisible. It warned to the console, where the person who
would simply have generated again could not see it, so they kept the plainer
deck without knowing a better one was one click away. The response now carries
deckFallback and the UI says it came out as plain slides, and why.

Fixed the reason heuristic while adding tests for it: truncation was claimed for
any reply not ending in "}", which is every prose refusal. It is now only
claimed for a reply that began as JSON and stopped.

The four generate tests run the handler. Verified against a mutation: removing
the retry fails tests 6 and 7 and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 05:34:14 +02:00

511 lines
22 KiB
JavaScript

// ============================================================
// MY RESOURCES
// A person's own generated teaching material.
//
// Separate from the Learning Hub on purpose: that is moderator-owned content
// published into categories for everyone, this is private and needs no role
// beyond being signed in. The server enforces that independently — every query
// there filters on the owner — so this only has to be an honest interface to it.
// ============================================================
(function () {
var inited = false;
document.addEventListener('tabChanged', function (e) {
if (!e.detail || e.detail.tab !== 'myresources') return;
if (!inited) { init(); inited = true; }
loadLibrary();
});
// What an administrator has switched on. Read once, used by both option
// groups and by the instruction check.
var available = { images: false, web: false, pubmed: false };
// Someone who writes "add a diagram of the airway" in the instructions has
// said what they want as plainly as ticking the box would. Left alone, the
// request is simply dropped and the resource comes back without a picture
// and without a word about why. These are the ways people actually ask.
var IMAGE_REQUEST = /\b(image|images|diagram|diagrams|illustration|illustrations|illustrate|illustrated|figure|figures|picture|pictures|drawing|drawings|chart|charts|graphic|graphics|infographic|visual|visuals|schematic)\b/i;
function looksLikeImageRequest(text) {
return IMAGE_REQUEST.test(String(text || ''));
}
// Turning the option on is the honest response: it does what was asked, it is
// visible, and it can be turned straight back off. Silently ignoring the
// sentence is not. Once it has been switched off by hand, it stays off —
// re-ticking it on every keystroke would be a fight.
function wireImageIntent(textareaId, checkboxId, hintId) {
var box = document.getElementById(textareaId);
var check = document.getElementById(checkboxId);
var hint = document.getElementById(hintId);
if (!box || !check || !hint) return;
var overruled = false;
check.addEventListener('change', function () {
if (!check.checked) overruled = true;
if (check.checked) hint.hidden = true;
});
box.addEventListener('input', function () {
var asked = looksLikeImageRequest(box.value);
if (!asked) { hint.hidden = true; return; }
if (!available.images) {
hint.textContent = 'Your instructions mention a figure, but no image model is configured, so none can be made.';
hint.hidden = false;
return;
}
if (check.checked || overruled) {
hint.hidden = overruled ? false : true;
if (overruled) hint.textContent = 'Your instructions ask for a figure. Tick the illustration option above to get one.';
return;
}
check.checked = true;
hint.textContent = 'Illustration switched on, because your instructions ask for a figure. Untick it if you would rather not.';
hint.hidden = false;
});
}
function init() {
var kind = document.getElementById('mr-kind');
if (kind) kind.addEventListener('change', syncFormatFields);
syncFormatFields();
var generate = document.getElementById('btn-mr-generate');
if (generate) generate.addEventListener('click', runGenerate);
var refresh = document.getElementById('btn-mr-refresh');
if (refresh) refresh.addEventListener('click', loadLibrary);
// One delegated handler: rows are rebuilt on every refresh, so binding per
// row would leak listeners and miss anything added later.
var list = document.getElementById('mr-list');
if (list) list.addEventListener('click', onRowClick);
// Filtering is local: the whole library is already in hand, so searching it
// is instant and costs no request.
var search = document.getElementById('mr-search');
if (search) search.addEventListener('input', renderLibrary);
var modify = document.getElementById('btn-mr-modify');
if (modify) modify.addEventListener('click', runModify);
wireImageIntent('mr-refinement', 'mr-with-images', 'mr-image-hint');
wireImageIntent('mr-modify-instructions', 'mr-modify-images', 'mr-modify-image-hint');
loadOptions();
}
// What an administrator has approved. The model row stays hidden unless there
// is a genuine choice to make — one approved model is not a decision anyone
// should be asked to take.
function loadOptions() {
fetch('/api/my-resources/options', { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data || !data.success) return;
var select = document.getElementById('mr-model');
var modelRow = document.getElementById('mr-model-row');
var models = data.models || [];
if (select) {
select.textContent = '';
models.forEach(function (id) {
var option = document.createElement('option');
option.value = id;
option.textContent = id;
if (id === data.defaultModel) option.selected = true;
select.appendChild(option);
});
}
if (modelRow) modelRow.hidden = models.length < 2;
// Hidden entirely unless an administrator enabled it, so an option
// never appears that someone could tick and then be refused. Both
// groups are driven from the same answer: Generate and Modify offer
// the same choices, so they must offer the same ones.
available = {
images: Boolean(data.imagesAvailable),
web: Boolean(data.webSearchAvailable),
pubmed: Boolean(data.pubmedAvailable)
};
[['mr-images-row', 'images'], ['mr-web-row', 'web'], ['mr-pubmed-row', 'pubmed'],
['mr-modify-images-row', 'images'], ['mr-modify-web-row', 'web'],
['mr-modify-pubmed-row', 'pubmed']].forEach(function (pair) {
var row = document.getElementById(pair[0]);
if (row) row.hidden = !available[pair[1]];
});
})
.catch(function () { /* the defaults still work without this */ });
}
function syncFormatFields() {
var isArticle = (document.getElementById('mr-kind') || {}).value === 'article';
var slides = document.getElementById('mr-slide-count');
var words = document.getElementById('mr-word-wrap');
if (slides && slides.parentElement) slides.parentElement.hidden = isArticle;
if (words) words.hidden = !isArticle;
}
function status(text, tone) {
var el = document.getElementById('mr-status');
if (!el) return;
el.textContent = text || '';
el.style.color = tone === 'bad' ? 'var(--red)' : tone === 'good' ? 'var(--green)' : 'var(--g600)';
}
function runGenerate() {
var topic = (document.getElementById('mr-topic') || {}).value || '';
if (!topic.trim()) { status('Enter a topic first.', 'bad'); return; }
var btn = document.getElementById('btn-mr-generate');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating...'; }
status('Searching the library and writing. This takes a moment.');
var corpusBox = document.getElementById('mr-use-corpus');
fetch('/api/my-resources/generate', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
topic: topic.trim(),
kind: (document.getElementById('mr-kind') || {}).value || 'presentation',
slideCount: (document.getElementById('mr-slide-count') || {}).value,
wordCount: (document.getElementById('mr-word-count') || {}).value,
refinement: (document.getElementById('mr-refinement') || {}).value || '',
useCorpus: corpusBox && corpusBox.checked === false ? 'false' : 'true',
model: (document.getElementById('mr-model') || {}).value || '',
withImages: (document.getElementById('mr-with-images') || {}).checked ? 'true' : 'false',
withWebSearch: (document.getElementById('mr-web-search') || {}).checked ? 'true' : 'false',
withPubmed: (document.getElementById('mr-pubmed') || {}).checked ? 'true' : 'false'
})
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Generation failed');
// Say what it was written from. Ungrounded material presented as
// grounded is the failure worth preventing.
var g = data.grounding || {};
status(g.used
? 'Saved. Written from ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') + '.'
: 'Saved. Not grounded' + (g.reason ? ' — ' + g.reason : '') + '; written from the model alone.',
g.used ? 'good' : null);
reportSearches(data.searches);
// A deck that fell back came out as plain slides. Silently handing
// someone the plainer artifact left them comparing two decks with no
// idea why one had layouts and the other did not — and asking again
// usually gets the designed one, which is only worth knowing if the
// fallback is visible.
if (data.deckFallback) {
status('Saved, but as plain slides: the model could not produce a slide ' +
'design twice (' + data.deckFallback + '). Generating again usually gets one.', 'bad');
}
showIllustrations(data.imageJobs || []);
reportImageFailures(data.imageFailures);
loadLibrary();
})
.catch(function (err) { status(err.message, 'bad'); })
.finally(function () {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-wand-magic-sparkles"></i> Generate'; }
});
}
// Say what was searched for. A query that left the network is worth showing
// plainly rather than leaving someone to wonder whether it happened.
function reportSearches(searches) {
(searches || []).forEach(function (s) {
if (typeof showToast !== 'function') return;
var where = s.tool === 'pubmed_search' ? 'PubMed' : 'the web';
showToast(s.count
? 'Searched ' + where + ' for "' + s.query + '" \u2014 ' + s.count + ' used.'
: 'Nothing found on ' + where + ' for "' + s.query + '".', 'info');
});
}
function ticked(id) {
var el = document.getElementById(id);
return Boolean(el && el.checked);
}
// The shared poller, so an image made here behaves exactly like one made in
// the assistant: same status line, same durable job, same asset endpoint. It
// lives in an ES module and this file is a classic script, hence the dynamic
// import — which also means a failure to load it cannot break generation.
function reportImageFailures(failures) {
if (!(failures || []).length || typeof showToast !== 'function') return;
// Fewer pictures than asked for, without a word, would look like the model
// ignoring the request rather than the queue refusing it.
showToast(failures.length === 1
? 'One illustration could not be started: ' + failures[0]
: failures.length + ' illustrations could not be started.', 'error');
}
function showIllustrations(jobs) {
var box = document.getElementById('mr-images');
if (!box) return;
box.innerHTML = '';
if (!jobs.length) return;
import('/js/generatedImages.js').then(function (m) {
m.renderImageJobs(box, jobs, 'my_resources', function (card, data) {
var img = document.createElement('img');
img.alt = 'Generated teaching illustration';
img.style.maxWidth = '100%';
card.append(img);
m.hydrateImage(img, data.imageUrl).catch(function () { img.alt = 'Private image unavailable'; });
});
}).catch(function () {
if (typeof showToast === 'function') showToast('An illustration was generated but could not be displayed.', 'info');
});
}
// The library as last fetched. Held so searching and the Modify picker both
// work from one copy rather than each asking the server again.
var library = [];
function loadLibrary() {
var list = document.getElementById('mr-list');
if (!list) return;
fetch('/api/my-resources', { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (data) {
library = (data && data.resources) || [];
renderLibrary();
syncModifyTargets();
})
.catch(function () {
library = [];
list.textContent = '';
var failed = document.createElement('p');
failed.style.cssText = 'margin:0;font-size:13px;color:var(--red);';
failed.textContent = 'Could not load your resources.';
list.appendChild(failed);
syncModifyTargets();
});
}
function matches(row, needle) {
if (!needle) return true;
return (String(row.title || '') + ' ' + String(row.topic || '')).toLowerCase().indexOf(needle) !== -1;
}
function renderLibrary() {
var list = document.getElementById('mr-list');
if (!list) return;
var search = document.getElementById('mr-search');
var needle = String((search && search.value) || '').trim().toLowerCase();
var rows = library.filter(function (row) { return matches(row, needle); });
list.textContent = '';
if (!rows.length) {
var empty = document.createElement('p');
empty.style.cssText = 'margin:0;font-size:13px;color:var(--g400);';
// Two different situations, and saying "nothing yet" to someone whose
// search simply missed would be wrong.
empty.textContent = library.length
? 'Nothing matches "' + needle + '".'
: 'Nothing yet. Generate something above and it will appear here.';
list.appendChild(empty);
return;
}
rows.forEach(function (row) { list.appendChild(renderRow(row)); });
}
// The Modify picker is the same library, so it is rebuilt from it — and the
// selection survives a refresh, because losing it mid-sentence is maddening.
function syncModifyTargets() {
var select = document.getElementById('mr-modify-target');
if (!select) return;
var previous = select.value;
select.textContent = '';
if (!library.length) {
var none = document.createElement('option');
none.value = '';
none.textContent = 'Nothing to modify yet';
select.appendChild(none);
select.disabled = true;
return;
}
select.disabled = false;
library.forEach(function (row) {
var option = document.createElement('option');
option.value = String(row.id);
option.textContent = (row.title || 'Untitled') +
' \u2014 ' + (row.kind === 'article' ? 'article'
: row.has_deck === false ? 'presentation, plain text' : 'presentation');
select.appendChild(option);
});
if (previous && library.some(function (row) { return String(row.id) === previous; })) {
select.value = previous;
}
}
function runModify() {
var select = document.getElementById('mr-modify-target');
var box = document.getElementById('mr-modify-instructions');
var status = document.getElementById('mr-modify-status');
var btn = document.getElementById('btn-mr-modify');
function say(message, tone) {
if (!status) return;
status.textContent = message || '';
status.style.color = tone === 'bad' ? 'var(--red)' : tone === 'good' ? 'var(--green)' : 'var(--g600)';
}
var id = select && select.value;
var instructions = String((box && box.value) || '').trim();
if (!id) return say('Nothing to modify yet.', 'bad');
if (!instructions) return say('Say what to change.', 'bad');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Applying'; }
say('Rewriting…');
fetch('/api/my-resources/' + encodeURIComponent(id) + '/refine', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
instructions: instructions,
useCorpus: ticked('mr-modify-corpus') ? 'true' : 'false',
withPubmed: ticked('mr-modify-pubmed') ? 'true' : 'false',
withWebSearch: ticked('mr-modify-web-search') ? 'true' : 'false',
withImages: ticked('mr-modify-images') ? 'true' : 'false',
model: (document.getElementById('mr-model') || {}).value || ''
})
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Could not apply the changes');
// Same reporting as generating: what it was written from, what was
// searched, and any figure that came back.
var g = data.grounding || {};
// The model can return the document back unchanged. That is a failed
// modification, and saying "Applied" for it sent people off to download
// an identical file and conclude the feature was broken.
if (data.unchanged) {
say('The model returned it unchanged — nothing was modified. ' +
'Try naming the slide or section to change, and what to change about it.', 'bad');
} else {
say('Applied' + (g.used ? ', using ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') : '') +
'. Download it to see the result.', 'good');
}
reportSearches(data.searches);
showIllustrations(data.imageJobs || []);
reportImageFailures(data.imageFailures);
if (box && !data.unchanged) box.value = '';
loadLibrary();
})
.catch(function (err) { say(err.message, 'bad'); })
.finally(function () {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-pen-to-square"></i> Apply changes'; }
});
}
// Built as elements rather than innerHTML: a title comes from a model, and
// this is the one place it reaches the page.
function renderRow(row) {
var wrap = document.createElement('div');
wrap.className = 'saved-enc-item';
wrap.style.cssText = 'padding:8px 12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;';
var body = document.createElement('div');
body.style.flex = '1';
body.style.minWidth = '180px';
var title = document.createElement('div');
title.style.cssText = 'font-weight:600;font-size:13px;';
title.textContent = row.title || 'Untitled';
// The modified time, when there is one. Showing only the creation time meant
// a modification that did apply still looked like nothing had happened, so
// the timestamp was the thing people used to conclude modify was broken.
var created = new Date(row.created_at);
var updated = row.updated_at ? new Date(row.updated_at) : created;
var edited = updated - created > 2000;
var meta = document.createElement('div');
meta.style.cssText = 'font-size:11px;color:var(--g500);';
meta.textContent = (row.kind === 'article' ? 'Article' : 'Presentation') +
' · ' + (edited ? 'modified ' + updated.toLocaleString()
: created.toLocaleString()) +
(row.grounded_count ? ' · ' + row.grounded_count + ' library excerpts' : ' · not grounded') +
// Only worth saying when it is the weaker kind. A deck is the normal case.
(row.kind !== 'article' && row.has_deck === false ? ' · plain text, no slide layout' : '');
body.appendChild(title);
body.appendChild(meta);
wrap.appendChild(body);
// An article has no slides, so offering PowerPoint would produce a deck of
// paragraphs. A presentation as Word is fine — prose absorbs slide content
// without overflowing anything.
var formats = row.kind === 'article' ? ['docx', 'pdf'] : ['pptx', 'docx', 'pdf'];
formats.forEach(function (format) {
var btn = document.createElement('button');
btn.className = 'btn-sm btn-ghost';
btn.type = 'button';
btn.dataset.download = String(row.id);
btn.dataset.format = format;
btn.textContent = format.toUpperCase();
btn.title = 'Download as ' + format.toUpperCase();
wrap.appendChild(btn);
});
var del = document.createElement('button');
del.className = 'btn-sm btn-ghost';
del.type = 'button';
del.dataset.remove = String(row.id);
del.style.color = 'var(--red)';
del.title = 'Delete';
var icon = document.createElement('i');
icon.className = 'fas fa-trash';
del.appendChild(icon);
wrap.appendChild(del);
return wrap;
}
function onRowClick(event) {
var download = event.target.closest && event.target.closest('[data-download]');
if (download) return downloadResource(download.dataset.download, download.dataset.format, download);
var remove = event.target.closest && event.target.closest('[data-remove]');
if (remove) {
showConfirm('Delete this resource? This cannot be undone.', function () {
fetch('/api/my-resources/' + encodeURIComponent(remove.dataset.remove), {
method: 'DELETE', headers: getAuthHeaders()
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) throw new Error(d.error || 'Could not delete');
loadLibrary();
})
.catch(function (err) { showToast(err.message, 'error'); });
}, { danger: true, confirmText: 'Delete' });
}
}
// Fetched rather than linked, because the download needs the auth header and
// an <a href> cannot carry one.
function downloadResource(id, format, btn) {
var original = btn.textContent;
btn.disabled = true;
btn.textContent = '...';
fetch('/api/my-resources/' + encodeURIComponent(id) + '/export?format=' + encodeURIComponent(format), {
headers: getAuthHeaders()
})
.then(function (r) {
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Download failed'); });
var name = 'resource.' + format;
var disposition = r.headers.get('Content-Disposition') || '';
var match = disposition.match(/filename="([^"]+)"/);
if (match) name = match[1];
return r.blob().then(function (blob) { saveBlob(blob, name); });
})
.catch(function (err) { showToast(err.message, 'error'); })
.finally(function () { btn.disabled = false; btn.textContent = original; });
}
function saveBlob(blob, name) {
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = name;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}
}());