feat: My Resources says what it is, offers its sources in one place, and Modify gets them too
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m59s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m59s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The screen had been trimmed to controls with no explanation of what it was for; "Private to you" said who could see it, not what it did. It now opens with a sentence that says what you get and how you get it out, and the header reads "Only you can see these". The four choices — clinical library, PubMed, web, illustration — are one "Draw on" group instead of four separate rows, with the library ticked by default. Each option hides itself when an administrator has not enabled it, so nothing appears that a person could tick and then be refused. Modify offers exactly the same choices. It had none, so "add what the 2024 trial showed" was answered from the model's memory rather than by looking anything up. Generate and Modify now go through one gatherSources(), so they cannot drift into offering different things or searching them differently. Writing "include a diagram of the airway" in the instructions now switches the illustration option on and says why, rather than the request being dropped in silence. Switching it off by hand sticks — the hint then reminds instead of fighting — and when no image model is configured it says so rather than pretending. Both the generate and modify boxes behave this way. Two things found by testing this rather than assuming it. PubMed ANDs every mapped term, so one unrecognised word takes the query to zero. "febrile seizures" returns six results and "febrile seizures in under-fives" returns none; "the anatomy of croup: subglottic narrowing and the steeple sign" returned none until it was narrowed to "anatomy croup", which returns six. A query that finds nothing is now retried against progressively shorter versions of itself, longest first, and the response says which query actually worked so the screen cannot report one that found nothing. Those extra calls tripped NCBI's three-a-second limit and produced a 429, so retries are spaced and the first attempt waits for nothing. Separately, the searches run on the topic while the library retrieval also gets the instruction: retrieval is semantic and benefits from the context, but a keyword engine handed a whole sentence returns nothing. And when a search was asked for and came back empty, the prompt now says not to invent a citation, a PMID or a URL to fill the gap. Without that the model supplies them from memory, and a fabricated PMID looks exactly like a real one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
05dcd1146d
commit
055a86bbb1
6 changed files with 411 additions and 123 deletions
|
|
@ -1,9 +1,14 @@
|
|||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-folder-open"></i> My Resources</h3>
|
||||
<span style="font-size:12px;color:var(--g500);">Private to you</span>
|
||||
<span style="font-size:12px;color:var(--g500);">Only you can see these</span>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
|
||||
<p style="margin:0;font-size:13px;color:var(--g600);line-height:1.5;">
|
||||
Build a teaching deck or handout on any topic — grounded in the clinical library,
|
||||
the literature, or both — then revise it below and download it as PowerPoint,
|
||||
Word or PDF.
|
||||
</p>
|
||||
<div class="admin-row">
|
||||
<label for="mr-topic" class="admin-row-label">Topic</label>
|
||||
<input id="mr-topic" type="text" class="admin-control" placeholder="e.g. febrile seizures in under-fives" maxlength="300">
|
||||
|
|
@ -27,16 +32,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-row">
|
||||
<strong class="admin-row-label">Clinical library</strong>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;">
|
||||
<input type="checkbox" id="mr-use-corpus" checked>
|
||||
Write from the indexed clinical library
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-row" id="mr-model-row" hidden>
|
||||
<label for="mr-model" class="admin-row-label">Model</label>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||
|
|
@ -44,39 +39,33 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-row" id="mr-images-row" hidden>
|
||||
<strong class="admin-row-label">Illustrations</strong>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||
<!-- One group rather than four rows. Each option hides itself when an
|
||||
administrator has not enabled it, so nothing appears that a person
|
||||
could tick and then be refused. -->
|
||||
<div class="admin-row" style="align-items:flex-start;">
|
||||
<strong class="admin-row-label">Draw on</strong>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:8px;min-width:0;">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;">
|
||||
<input type="checkbox" id="mr-with-images">
|
||||
Add one illustration — the model's choice, or say what to draw in Instructions
|
||||
<input type="checkbox" id="mr-use-corpus" checked>
|
||||
The clinical library
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-row" id="mr-web-row" hidden>
|
||||
<strong class="admin-row-label">Web search</strong>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;">
|
||||
<input type="checkbox" id="mr-web-search">
|
||||
Look it up online
|
||||
</label>
|
||||
<p style="margin:0;font-size:12px;color:var(--g500);">
|
||||
<strong>The search query leaves this network.</strong> Do not put anything identifying in the topic.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-row" id="mr-pubmed-row" hidden>
|
||||
<strong class="admin-row-label">PubMed</strong>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;" id="mr-pubmed-row" hidden>
|
||||
<input type="checkbox" id="mr-pubmed">
|
||||
Cite published literature
|
||||
PubMed — cite published literature
|
||||
<span style="font-size:12px;color:var(--g500);">The query goes to NCBI.</span>
|
||||
</label>
|
||||
<p style="margin:0;font-size:12px;color:var(--g500);">
|
||||
<strong>The search query goes to NCBI.</strong>
|
||||
</p>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;flex-wrap:wrap;" id="mr-web-row" hidden>
|
||||
<input type="checkbox" id="mr-web-search">
|
||||
The web — for anything newer than the library
|
||||
<span style="font-size:12px;color:var(--g500);">The query leaves this network; keep the topic non-identifying.</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;" id="mr-images-row" hidden>
|
||||
<input type="checkbox" id="mr-with-images">
|
||||
Add one illustration
|
||||
</label>
|
||||
<!-- Filled in when the instructions ask for a figure the options do not
|
||||
allow. Silence there is the failure worth avoiding. -->
|
||||
<p id="mr-image-hint" style="margin:0;font-size:12px;color:#92400e;" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -111,6 +100,35 @@
|
|||
<select id="mr-modify-target" class="admin-control" style="max-width:420px;"></select>
|
||||
</div>
|
||||
|
||||
<!-- The same choices as generating. A modification can need material the
|
||||
original never had — "add what the 2024 trial showed" is a request for
|
||||
sources, not a rewording — so the options are offered again rather
|
||||
than inherited from whatever was ticked at the time. -->
|
||||
<div class="admin-row" style="align-items:flex-start;">
|
||||
<strong class="admin-row-label">Draw on</strong>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:8px;min-width:0;">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;">
|
||||
<input type="checkbox" id="mr-modify-corpus" checked>
|
||||
The clinical library
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;" id="mr-modify-pubmed-row" hidden>
|
||||
<input type="checkbox" id="mr-modify-pubmed">
|
||||
PubMed — cite published literature
|
||||
<span style="font-size:12px;color:var(--g500);">The query goes to NCBI.</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;flex-wrap:wrap;" id="mr-modify-web-row" hidden>
|
||||
<input type="checkbox" id="mr-modify-web-search">
|
||||
The web — for anything newer than the library
|
||||
<span style="font-size:12px;color:var(--g500);">The query leaves this network.</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;" id="mr-modify-images-row" hidden>
|
||||
<input type="checkbox" id="mr-modify-images">
|
||||
Add one illustration
|
||||
</label>
|
||||
<p id="mr-modify-image-hint" style="margin:0;font-size:12px;color:#92400e;" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-row" style="align-items:flex-start;">
|
||||
<label for="mr-modify-instructions" class="admin-row-label">What to change</label>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||
|
|
|
|||
|
|
@ -17,6 +17,53 @@
|
|||
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);
|
||||
|
|
@ -41,6 +88,9 @@
|
|||
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();
|
||||
}
|
||||
|
||||
|
|
@ -66,14 +116,22 @@
|
|||
});
|
||||
}
|
||||
if (modelRow) modelRow.hidden = models.length < 2;
|
||||
var imagesRow = document.getElementById('mr-images-row');
|
||||
if (imagesRow) imagesRow.hidden = !data.imagesAvailable;
|
||||
// Hidden entirely unless an administrator enabled it, so the option
|
||||
// never appears as something a user could turn on and be refused.
|
||||
var webRow = document.getElementById('mr-web-row');
|
||||
if (webRow) webRow.hidden = !data.webSearchAvailable;
|
||||
var pubmedRow = document.getElementById('mr-pubmed-row');
|
||||
if (pubmedRow) pubmedRow.hidden = !data.pubmedAvailable;
|
||||
|
||||
// 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 */ });
|
||||
}
|
||||
|
|
@ -128,15 +186,7 @@
|
|||
? '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);
|
||||
// Say what was searched for. A query that left the network is worth
|
||||
// showing plainly rather than leaving someone to wonder.
|
||||
(data.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 + '" — ' + s.count + ' used.'
|
||||
: 'Nothing found on ' + where + ' for "' + s.query + '".', 'info');
|
||||
});
|
||||
reportSearches(data.searches);
|
||||
showIllustrations(data.imageJobs || []);
|
||||
loadLibrary();
|
||||
})
|
||||
|
|
@ -146,6 +196,23 @@
|
|||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -271,13 +338,23 @@
|
|||
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');
|
||||
say('Applied. The library entry is updated; download it to see the result.', 'good');
|
||||
// Same reporting as generating: what it was written from, what was
|
||||
// searched, and any figure that came back.
|
||||
var g = data.grounding || {};
|
||||
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 || []);
|
||||
if (box) box.value = '';
|
||||
loadLibrary();
|
||||
})
|
||||
|
|
|
|||
|
|
@ -97,6 +97,13 @@ function buildPrompt(opts) {
|
|||
"library, and list what you use in the References section by title and URL.\n\n" +
|
||||
"WEB RESULTS:\n\"\"\"\n" + opts.webFindings + "\n\"\"\"\n";
|
||||
}
|
||||
// Asked to search, found nothing, and still asked for citations: without this
|
||||
// the model supplies them from memory, and a fabricated PMID looks exactly
|
||||
// like a real one.
|
||||
if (opts.searchedAndFoundNothing) {
|
||||
findings += "\nThe search for this topic returned nothing. Do not invent a citation, a PMID " +
|
||||
"or a URL to fill the gap.\n";
|
||||
}
|
||||
|
||||
// The image tool is the one thing still left to the model to decide, so the
|
||||
// prompt has to say it exists. Handing over a tool schema and then writing
|
||||
|
|
@ -172,6 +179,58 @@ router.get('/my-resources/options', async function (req, res) {
|
|||
}
|
||||
});
|
||||
|
||||
// ── Sources ─────────────────────────────────────────────────
|
||||
// What a resource is written from. Generating and modifying ask exactly the
|
||||
// same question — which library, which literature, which web — so they ask it
|
||||
// through one function rather than two that drift apart.
|
||||
//
|
||||
// Nothing in here may fail the request. A retrieval or a search that comes back
|
||||
// empty is reported as a reason, and the model writes from what it has.
|
||||
// `subject` is what the library is searched with: retrieval is semantic, so the
|
||||
// more context the better. `keywords` is what PubMed and the web are searched
|
||||
// with, and they are keyword engines — handed a whole sentence they return
|
||||
// nothing. Measured: "febrile seizures in under-fives — Add a slide on what the
|
||||
// randomised trial evidence shows, citing PMIDs." returned 0 results where the
|
||||
// topic alone returned six.
|
||||
async function gatherSources(subject, body, keywords) {
|
||||
keywords = String(keywords || subject).trim() || subject;
|
||||
var useCorpus = String(body.useCorpus) !== 'false';
|
||||
var wantsImages = String(body.withImages) === 'true' || body.withImages === true;
|
||||
var wantsWeb = (String(body.withWebSearch) === 'true' || body.withWebSearch === true)
|
||||
&& await webSearch.isAvailable();
|
||||
var wantsPubmed = (String(body.withPubmed) === 'true' || body.withPubmed === true)
|
||||
&& await pubmedSearch.isAvailable();
|
||||
|
||||
var corpus = { sources: [], context: '', reason: 'not requested' };
|
||||
if (useCorpus) corpus = await learningRetrieval.retrieve(subject, db.getSetting);
|
||||
|
||||
var searches = [];
|
||||
var literature = '';
|
||||
var webFindings = '';
|
||||
if (wantsPubmed) {
|
||||
var papers = await pubmedSearch.search(keywords);
|
||||
searches.push({ tool: 'pubmed_search', query: papers.query || keywords, count: papers.results.length, reason: papers.reason });
|
||||
if (papers.results.length) literature = pubmedSearch.formatForPrompt(papers.results);
|
||||
}
|
||||
if (wantsWeb) {
|
||||
var pages = await webSearch.search(keywords);
|
||||
searches.push({ tool: 'web_search', query: keywords, count: pages.results.length, reason: pages.reason });
|
||||
if (pages.results.length) webFindings = webSearch.formatForPrompt(pages.results);
|
||||
}
|
||||
|
||||
// Asking for an illustration when no image model is configured is not an
|
||||
// error, it just cannot happen; the caller reports that rather than failing.
|
||||
var imageModel = wantsImages ? String(await db.getSetting('clinical_assistant.image_model', '') || '') : '';
|
||||
return {
|
||||
corpus: corpus, searches: searches, literature: literature, webFindings: webFindings,
|
||||
// A search that was asked for and came back empty is the dangerous case: the
|
||||
// model is being asked for citations with nothing to cite, and will supply
|
||||
// them from memory unless told not to.
|
||||
searchedAndFoundNothing: searches.length > 0 && !literature && !webFindings,
|
||||
wantsImages: Boolean(wantsImages && imageModel), imageModel: imageModel
|
||||
};
|
||||
}
|
||||
|
||||
// ── Generate ────────────────────────────────────────────────
|
||||
router.post('/my-resources/generate', async function (req, res) {
|
||||
try {
|
||||
|
|
@ -179,7 +238,6 @@ router.post('/my-resources/generate', async function (req, res) {
|
|||
if (!topic) return res.status(400).json({ error: 'A topic is required' });
|
||||
|
||||
var kind = normalizeKind(req.body.kind);
|
||||
var useCorpus = String(req.body.useCorpus) !== 'false';
|
||||
var refinement = String(req.body.refinement || '').slice(0, 2000);
|
||||
|
||||
var count = await db.get('SELECT COUNT(*)::int AS n FROM user_resources WHERE user_id = ?', [req.user.id]);
|
||||
|
|
@ -187,43 +245,17 @@ router.post('/my-resources/generate', async function (req, res) {
|
|||
return res.status(409).json({ error: 'You have reached ' + MAX_PER_USER + ' saved resources. Delete one first.' });
|
||||
}
|
||||
|
||||
// Retrieval never fails a generation; the resource is then written from the
|
||||
// model alone, and the response says so.
|
||||
var corpus = { sources: [], context: '', reason: 'not requested' };
|
||||
if (useCorpus) corpus = await learningRetrieval.retrieve(topic, db.getSetting);
|
||||
|
||||
// Illustration is opt-in per generation. The tool is only offered when the
|
||||
// author ticked the box, because a model handed a drawing tool will find a
|
||||
// reason to use it, and most teaching material does not want one.
|
||||
var wantsImages = String(req.body.withImages) === 'true' || req.body.withImages === true;
|
||||
// Off unless asked for, and unavailable unless an administrator enabled it.
|
||||
// This is the only path here that sends text outside the building.
|
||||
var wantsWeb = (String(req.body.withWebSearch) === 'true' || req.body.withWebSearch === true)
|
||||
&& await webSearch.isAvailable();
|
||||
var wantsPubmed = (String(req.body.withPubmed) === 'true' || req.body.withPubmed === true)
|
||||
&& await pubmedSearch.isAvailable();
|
||||
var imageModel = wantsImages ? String(await db.getSetting('clinical_assistant.image_model', '') || '') : '';
|
||||
|
||||
// Both searches run up front on the topic, so their results are in the
|
||||
// prompt before a word is written. Neither can fail the generation.
|
||||
var searches = [];
|
||||
var literature = '';
|
||||
var webFindings = '';
|
||||
if (wantsPubmed) {
|
||||
var papers = await pubmedSearch.search(topic);
|
||||
searches.push({ tool: 'pubmed_search', query: topic, count: papers.results.length, reason: papers.reason });
|
||||
if (papers.results.length) literature = pubmedSearch.formatForPrompt(papers.results);
|
||||
}
|
||||
if (wantsWeb) {
|
||||
var pages = await webSearch.search(topic);
|
||||
searches.push({ tool: 'web_search', query: topic, count: pages.results.length, reason: pages.reason });
|
||||
if (pages.results.length) webFindings = webSearch.formatForPrompt(pages.results);
|
||||
}
|
||||
var sources = await gatherSources(topic, req.body);
|
||||
var corpus = sources.corpus;
|
||||
var searches = sources.searches;
|
||||
var wantsImages = sources.wantsImages;
|
||||
var imageModel = sources.imageModel;
|
||||
|
||||
var prompt = buildPrompt({
|
||||
topic: topic, kind: kind, refinement: refinement, corpusContext: corpus.context,
|
||||
literature: literature, webFindings: webFindings,
|
||||
wantsImages: Boolean(wantsImages && imageModel),
|
||||
literature: sources.literature, webFindings: sources.webFindings,
|
||||
searchedAndFoundNothing: sources.searchedAndFoundNothing,
|
||||
wantsImages: wantsImages,
|
||||
slideCount: clampInt(req.body.slideCount, 3, 30, 8),
|
||||
wordCount: clampInt(req.body.wordCount, 200, 3000, 800)
|
||||
});
|
||||
|
|
@ -237,14 +269,14 @@ router.post('/my-resources/generate', async function (req, res) {
|
|||
// decide and to compose a prompt; a search only needs the topic, and the
|
||||
// topic is already known.
|
||||
var tools = [];
|
||||
if (wantsImages && imageModel) tools = tools.concat(imageTool.tools);
|
||||
if (wantsImages) tools = tools.concat(imageTool.tools);
|
||||
|
||||
var ai = await callAI(messages, tools.length ? Object.assign({}, options, { tools: tools }) : options);
|
||||
|
||||
// The same dispatcher the assistant uses, so an image generated here is
|
||||
// owned, queued and rendered exactly as one generated there. Without this
|
||||
// the model's tool call is simply dropped and no job is ever enqueued.
|
||||
if (wantsImages && imageModel) {
|
||||
if (wantsImages) {
|
||||
ai = await imageTool.dispatch(ai, {
|
||||
owner: req.user.id, workflow: 'my_resources', body: req.body,
|
||||
imageContext: generatedImages.imageContext(topic, []), imageModel: imageModel,
|
||||
|
|
@ -332,20 +364,64 @@ router.post('/my-resources/:id/refine', async function (req, res) {
|
|||
if (!instructions) return res.status(400).json({ error: 'Say what to change' });
|
||||
|
||||
var existing = await db.get(
|
||||
'SELECT id, kind, markdown FROM user_resources WHERE id = ? AND user_id = ?',
|
||||
'SELECT id, kind, topic, markdown FROM user_resources WHERE id = ? AND user_id = ?',
|
||||
[parseInt(req.params.id, 10), req.user.id]
|
||||
);
|
||||
if (!existing) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
// Modifying can reach for the same sources as generating: "add what the
|
||||
// 2024 trial showed" is a request for material, not just a rewording, and
|
||||
// without this it would be answered from the model's memory alone. The
|
||||
// subject searched is the resource's own topic plus the instruction, so a
|
||||
// request about something not in the original still finds it.
|
||||
var subject = [existing.topic, instructions].filter(Boolean).join(' \u2014 ').slice(0, 500);
|
||||
var sources = await gatherSources(subject, req.body, existing.topic || instructions);
|
||||
|
||||
var material = '';
|
||||
if (sources.corpus.context) {
|
||||
material += '\n\nLIBRARY EXCERPTS (prefer these over your own recall; add anything you use ' +
|
||||
'to the References section):\n"""\n' + sources.corpus.context + '\n"""';
|
||||
}
|
||||
if (sources.literature) {
|
||||
material += '\n\nPUBMED RESULTS (cite by PMID in the References section; cite nothing not ' +
|
||||
'listed here):\n"""\n' + sources.literature + '\n"""';
|
||||
}
|
||||
if (sources.webFindings) {
|
||||
material += '\n\nWEB RESULTS (list what you use in the References section by title and ' +
|
||||
'URL):\n"""\n' + sources.webFindings + '\n"""';
|
||||
}
|
||||
if (sources.searchedAndFoundNothing) {
|
||||
material += '\n\nThe search for this returned nothing. Do not invent a citation, a PMID or ' +
|
||||
'a URL to fill the gap: leave the References section as it is.';
|
||||
}
|
||||
|
||||
var illustration = sources.wantsImages
|
||||
? '\n\nAn illustration tool is available and the author has asked for one. If the ' +
|
||||
'instruction asks for a figure, or one would genuinely help, call generate_image ONCE ' +
|
||||
'with a description of it. Schematic or anatomical teaching artwork only, never a real ' +
|
||||
'patient. Returning the markdown is still required; the tool call is not a substitute ' +
|
||||
'for it, and no image tag or URL goes into the markdown.'
|
||||
: '';
|
||||
|
||||
// The markdown is the thing being edited, which is the whole reason it is
|
||||
// what gets stored: "change slide 4" is a text edit, not a binary patch.
|
||||
var ai = await callAI([{ role: 'user', content:
|
||||
var messages = [{ role: 'user', content:
|
||||
'Revise the following Pandoc markdown according to the instruction. ' +
|
||||
'Return ONLY the complete revised markdown, no commentary, no code fences. ' +
|
||||
'Keep the same overall structure unless the instruction asks otherwise, and keep any ' +
|
||||
'References section at the end.\n\nINSTRUCTION: ' + instructions +
|
||||
'\n\nMARKDOWN:\n"""\n' + existing.markdown + '\n"""' }],
|
||||
{ model: await resolveModel(req.body.model), temperature: 0.2 });
|
||||
'References section at the end.\n\nINSTRUCTION: ' + instructions + illustration + material +
|
||||
'\n\nMARKDOWN:\n"""\n' + existing.markdown + '\n"""' }];
|
||||
var options = { model: await resolveModel(req.body.model), temperature: 0.2 };
|
||||
var tools = sources.wantsImages ? imageTool.tools : [];
|
||||
|
||||
var ai = await callAI(messages, tools.length ? Object.assign({}, options, { tools: tools }) : options);
|
||||
if (sources.wantsImages) {
|
||||
ai = await imageTool.dispatch(ai, {
|
||||
owner: req.user.id, workflow: 'my_resources', body: req.body,
|
||||
imageContext: generatedImages.imageContext(subject, []), imageModel: sources.imageModel,
|
||||
messages: messages, options: options, callAI: callAI
|
||||
});
|
||||
}
|
||||
|
||||
var revised = String((ai && ai.content) || '').trim();
|
||||
if (!revised) return res.status(502).json({ error: 'The model returned nothing. Try again.' });
|
||||
|
|
@ -355,7 +431,14 @@ router.post('/my-resources/:id/refine', async function (req, res) {
|
|||
'WHERE id = ? AND user_id = ? RETURNING id, title, updated_at',
|
||||
[revised, firstHeading(revised), existing.id, req.user.id]
|
||||
);
|
||||
res.json({ success: true, resource: row, markdown: revised, model: ai && ai.model });
|
||||
res.json({
|
||||
success: true, resource: row, markdown: revised,
|
||||
grounding: { used: Boolean(sources.corpus.context), count: sources.corpus.sources.length,
|
||||
reason: sources.corpus.reason || null },
|
||||
searches: sources.searches,
|
||||
imageJobs: ai.imageJobs || [],
|
||||
model: ai && ai.model
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[my-resources] refine:', err.message);
|
||||
res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Refinement failed' });
|
||||
|
|
|
|||
|
|
@ -100,6 +100,39 @@ async function fetchAbstracts(pmids, s) {
|
|||
* Never throws, for the same reason corpus retrieval and web search do not: a
|
||||
* lookup failing must not fail the resource someone is writing.
|
||||
*/
|
||||
// PubMed ANDs every term after automatic mapping, so a single unrecognised word
|
||||
// takes the whole query to zero: "febrile seizures" returns six results,
|
||||
// "febrile seizures in under-fives" returns none. A topic written by a person is
|
||||
// full of such words, and a resource asking to be grounded in the literature
|
||||
// should not come back empty because of one of them.
|
||||
//
|
||||
// So a query that finds nothing is retried against progressively shorter
|
||||
// versions of itself, stopping at the first that finds something. The extra
|
||||
// requests only happen on the path that would otherwise have returned nothing.
|
||||
var STOPWORDS = new Set(('a an and are as at be but by for from how in into is it its of on or '
|
||||
+ 'that the their there these this to under over with what when where which who why show shows '
|
||||
+ 'showed evidence review overview update current recent latest new approach approaches '
|
||||
+ 'management use using guidance guidelines').split(' '));
|
||||
|
||||
function contentWords(text) {
|
||||
// Anything after a colon or dash is almost always a subtitle, not a subject.
|
||||
var head = String(text).split(/[:\u2014\u2013]|\s-\s/)[0];
|
||||
return head.toLowerCase().replace(/[^a-z0-9\s-]/g, ' ').split(/\s+/)
|
||||
.filter(function (w) { return w.length > 2 && !STOPWORDS.has(w); });
|
||||
}
|
||||
|
||||
// Longest first, so the most specific query that works is the one used.
|
||||
function candidates(text) {
|
||||
var words = contentWords(text);
|
||||
var tried = [String(text).trim()];
|
||||
for (var n = Math.min(words.length, 4); n >= 2; n--) {
|
||||
var candidate = words.slice(0, n).join(' ');
|
||||
if (candidate && tried.indexOf(candidate) === -1) tried.push(candidate);
|
||||
}
|
||||
if (words.length === 1 && tried.indexOf(words[0]) === -1) tried.push(words[0]);
|
||||
return tried;
|
||||
}
|
||||
|
||||
async function search(query) {
|
||||
var text = String(query || '').trim().slice(0, 400);
|
||||
if (!text) return { results: [], reason: 'empty query' };
|
||||
|
|
@ -107,14 +140,31 @@ async function search(query) {
|
|||
var s = await settings();
|
||||
if (!s.enabled) return { results: [], reason: 'PubMed search is disabled' };
|
||||
|
||||
var searchUrl = withCommon(new URL(BASE + '/esearch.fcgi'), s);
|
||||
searchUrl.searchParams.set('db', 'pubmed');
|
||||
searchUrl.searchParams.set('term', text);
|
||||
searchUrl.searchParams.set('retmode', 'json');
|
||||
searchUrl.searchParams.set('retmax', String(MAX_RESULTS));
|
||||
searchUrl.searchParams.set('sort', 'relevance');
|
||||
var found = await getJson(searchUrl);
|
||||
var pmids = ((found.esearchresult || {}).idlist || []).slice(0, MAX_RESULTS);
|
||||
async function idsFor(term) {
|
||||
var searchUrl = withCommon(new URL(BASE + '/esearch.fcgi'), s);
|
||||
searchUrl.searchParams.set('db', 'pubmed');
|
||||
searchUrl.searchParams.set('term', term);
|
||||
searchUrl.searchParams.set('retmode', 'json');
|
||||
searchUrl.searchParams.set('retmax', String(MAX_RESULTS));
|
||||
searchUrl.searchParams.set('sort', 'relevance');
|
||||
var found = await getJson(searchUrl);
|
||||
return ((found.esearchresult || {}).idlist || []).slice(0, MAX_RESULTS);
|
||||
}
|
||||
|
||||
// NCBI allows three requests a second without an API key and ten with one.
|
||||
// The ladder can make three esearch calls before the esummary and efetch
|
||||
// that follow, which is enough to trip that and turn a working search into a
|
||||
// 429 — measured, on exactly this path. So retries are spaced; the first
|
||||
// attempt, which is the usual case, waits for nothing.
|
||||
var tries = candidates(text);
|
||||
var gap = s.apiKey ? 120 : 380;
|
||||
var used = tries[0];
|
||||
var pmids = [];
|
||||
for (var t = 0; t < tries.length && !pmids.length; t++) {
|
||||
if (t) await new Promise(function (done) { setTimeout(done, gap); });
|
||||
used = tries[t];
|
||||
pmids = await idsFor(used);
|
||||
}
|
||||
if (!pmids.length) return { results: [], reason: 'no results' };
|
||||
|
||||
var summaryUrl = withCommon(new URL(BASE + '/esummary.fcgi'), s);
|
||||
|
|
@ -138,7 +188,10 @@ async function search(query) {
|
|||
abstract: abstracts[pmid] || ''
|
||||
};
|
||||
});
|
||||
return { results: results, reason: null };
|
||||
// Saying which query worked matters: the screen reports what was searched,
|
||||
// and reporting the phrase someone typed when a narrowed one found the
|
||||
// results would be a lie.
|
||||
return { results: results, reason: null, query: used };
|
||||
} catch (e) {
|
||||
return { results: [], reason: e.message || 'PubMed search failed' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ test('the screen is reachable by anyone signed in, and states that it is private
|
|||
|
||||
// Said in the header. It used to be repeated in a paragraph below; the claim
|
||||
// is what matters, not that it was made twice.
|
||||
assert.match(component, /Private to you/);
|
||||
assert.match(component, /Only you can see these/);
|
||||
});
|
||||
|
||||
test('a row offers the right formats, and the download carries its auth', () => {
|
||||
|
|
@ -132,7 +132,7 @@ test('illustration is opt-in, and reuses the assistant’s image tool', () => {
|
|||
const route = read('src/routes/myResources.js');
|
||||
// A model handed a drawing tool will find a reason to use it, so the tool is
|
||||
// only offered when the author asked for one.
|
||||
assert.match(route, /var wantsImages = String\(req\.body\.withImages\) === 'true'/);
|
||||
assert.match(route, /var wantsImages = String\(body\.withImages\) === 'true'/);
|
||||
// Opt-in is the checkbox's own default, which is the fact worth pinning —
|
||||
// stronger than the sentence that used to explain it.
|
||||
assert.match(read('public/components/my-resources.html'),
|
||||
|
|
@ -142,7 +142,7 @@ test('illustration is opt-in, and reuses the assistant’s image tool', () => {
|
|||
// model to decide there should be a picture and to compose the prompt for it.
|
||||
// Search does not — see web-search.test.js for why both searches were taken
|
||||
// away from the model and run by the route instead.
|
||||
assert.match(route, /if \(wantsImages && imageModel\) tools = tools\.concat\(imageTool\.tools\);/);
|
||||
assert.match(route, /if \(wantsImages\) tools = tools\.concat\(imageTool\.tools\);/);
|
||||
assert.doesNotMatch(route, /tools\.concat\((?:webSearch|pubmedSearch)\.tools\)/);
|
||||
// The same dispatcher the assistant uses, so an image made here is owned,
|
||||
// queued and rendered identically to one made there.
|
||||
|
|
@ -180,7 +180,7 @@ test('illustration is opt-in, and reuses the assistant’s image tool', () => {
|
|||
assert.match(route, /imageJobs: ai\.imageJobs \|\| \[\]/, 'and reported back');
|
||||
|
||||
// The row is hidden entirely when no image model is configured.
|
||||
assert.match(read('public/js/myResources.js'), /if \(imagesRow\) imagesRow\.hidden = !data\.imagesAvailable;/);
|
||||
assert.match(read('public/js/myResources.js'), /\['mr-images-row', 'images'\]/);
|
||||
});
|
||||
|
||||
test('the author can ask for the illustration, not only leave it to the model', () => {
|
||||
|
|
@ -200,9 +200,18 @@ test('the author can ask for the illustration, not only leave it to the model',
|
|||
// Said once on screen too: the label points at Instructions, and the
|
||||
// Instructions placeholder shows what asking for one looks like.
|
||||
const html = read('public/components/my-resources.html');
|
||||
assert.match(html, /say what to draw in Instructions/);
|
||||
assert.match(html, /include a diagram of the airway/);
|
||||
assert.match(html, /Add one illustration/, 'and the count is stated where the choice is made');
|
||||
|
||||
// Saying it in the instructions is as clear as ticking the box, so the box
|
||||
// follows rather than the request being dropped in silence.
|
||||
const js = read('public/js/myResources.js');
|
||||
assert.match(js, /function looksLikeImageRequest/);
|
||||
assert.match(js, /Illustration switched on, because your instructions ask for a figure/);
|
||||
assert.match(js, /no image model is configured, so none can be made/);
|
||||
assert.match(js, /if \(!check\.checked\) overruled = true;/, 'and switching it off by hand sticks');
|
||||
assert.match(js, /wireImageIntent\('mr-refinement', 'mr-with-images', 'mr-image-hint'\)/);
|
||||
assert.match(js, /wireImageIntent\('mr-modify-instructions', 'mr-modify-images', 'mr-modify-image-hint'\)/);
|
||||
});
|
||||
|
||||
test('the library is bounded, searchable, and drives the modify picker', () => {
|
||||
|
|
|
|||
|
|
@ -98,10 +98,16 @@ test('searching is the route\u2019s job, not something the model is asked to do'
|
|||
// it calls the model. So both searches run up front and their results go into
|
||||
// the prompt as findings, the same way corpus excerpts do.
|
||||
const route = read('src/routes/myResources.js');
|
||||
assert.match(route, /var wantsWeb = \(String\(req\.body\.withWebSearch\) === 'true'/);
|
||||
assert.match(route, /var wantsWeb = \(String\(body\.withWebSearch\) === 'true'/);
|
||||
assert.match(route, /&& await webSearch\.isAvailable\(\)/, 'and the server checks, not just the UI');
|
||||
assert.match(route, /var pages = await webSearch\.search\(topic\);/);
|
||||
assert.match(route, /var papers = await pubmedSearch\.search\(topic\);/);
|
||||
assert.match(route, /var pages = await webSearch\.search\(keywords\);/);
|
||||
assert.match(route, /var papers = await pubmedSearch\.search\(keywords\);/);
|
||||
// One function, so generating and modifying cannot drift into offering
|
||||
// different sources or searching them differently.
|
||||
assert.match(route, /async function gatherSources\(subject, body, keywords\)/);
|
||||
assert.match(route, /var sources = await gatherSources\(topic, req\.body\);/, 'generate');
|
||||
assert.match(route, /var sources = await gatherSources\(subject, req\.body, existing\.topic \|\| instructions\);/,
|
||||
'and modify, whose library search gets the instruction for context and whose keyword searches do not');
|
||||
|
||||
// Declared before they are used. They were not, once: `var` hoisting made
|
||||
// wantsPubmed undefined at the point of the test, so the block never ran and
|
||||
|
|
@ -112,8 +118,8 @@ test('searching is the route\u2019s job, not something the model is asked to do'
|
|||
|
||||
// Neither search may fail a generation, so what happened is reported back
|
||||
// instead: how many results, and why there were none.
|
||||
assert.match(route, /searches\.push\(\{ tool: 'pubmed_search', query: topic, count: papers\.results\.length, reason: papers\.reason \}\);/);
|
||||
assert.match(route, /searches\.push\(\{ tool: 'web_search', query: topic, count: pages\.results\.length, reason: pages\.reason \}\);/);
|
||||
assert.match(route, /searches\.push\(\{ tool: 'pubmed_search', query: papers\.query \|\| keywords/);
|
||||
assert.match(route, /searches\.push\(\{ tool: 'web_search', query: keywords, count: pages\.results\.length, reason: pages\.reason \}\);/);
|
||||
assert.match(route, /searches: searches/);
|
||||
|
||||
// And neither library still advertises itself as a tool.
|
||||
|
|
@ -121,6 +127,40 @@ test('searching is the route\u2019s job, not something the model is asked to do'
|
|||
assert.doesNotMatch(read('src/utils/pubmedSearch.js'), /name: 'pubmed_search'/);
|
||||
});
|
||||
|
||||
test('a PubMed query that finds nothing is narrowed rather than given up on', () => {
|
||||
const src = read('src/utils/pubmedSearch.js');
|
||||
// PubMed ANDs every mapped term, so one unrecognised word zeroes the query.
|
||||
// Measured against the live API: "febrile seizures" → 6 results, "febrile
|
||||
// seizures in under-fives" → 0, and "the anatomy of croup: subglottic
|
||||
// narrowing and the steeple sign" → 0 until it was narrowed to "anatomy
|
||||
// croup", which returns 6.
|
||||
assert.match(src, /function candidates\(text\)/);
|
||||
assert.match(src, /var tries = candidates\(text\);/);
|
||||
// Longest first, so the most specific query that works is the one used.
|
||||
assert.match(src, /for \(var n = Math\.min\(words\.length, 4\); n >= 2; n--\)/);
|
||||
// A subtitle is not a subject.
|
||||
assert.match(src, /split\(\/\[:\\u2014\\u2013\]\|\\s-\\s\/\)\[0\]/);
|
||||
|
||||
// Three esearch calls back to back trips NCBI's three-a-second limit without
|
||||
// a key, which turned a working search into a 429 — measured on this path.
|
||||
assert.match(src, /var gap = s\.apiKey \? 120 : 380;/);
|
||||
assert.match(src, /if \(t\) await new Promise/, 'and the first attempt waits for nothing');
|
||||
|
||||
// The screen reports what was searched, so it has to be the query that
|
||||
// actually found the results.
|
||||
assert.match(src, /return \{ results: results, reason: null, query: used \};/);
|
||||
assert.match(read('src/routes/myResources.js'), /query: papers\.query \|\| keywords/);
|
||||
});
|
||||
|
||||
test('a search that comes back empty is not an invitation to invent citations', () => {
|
||||
const route = read('src/routes/myResources.js');
|
||||
// Asked to search, found nothing, still asked for PMIDs: the model supplies
|
||||
// them from memory, and a fabricated PMID looks exactly like a real one.
|
||||
assert.match(route, /searchedAndFoundNothing: searches\.length > 0 && !literature && !webFindings/);
|
||||
// Said on both paths: refining and generating.
|
||||
assert.equal((route.match(/Do not invent a citation, a PMID/g) || []).length, 2);
|
||||
});
|
||||
|
||||
test('the key is masked on read and preserved when left blank', () => {
|
||||
const admin = read('src/routes/adminConfig.js');
|
||||
// Same handling the OIDC client secret gets.
|
||||
|
|
@ -137,9 +177,17 @@ test('both screens say plainly that a query leaves the network', () => {
|
|||
assert.match(read('public/components/admin.html'), /This sends text outside the building/);
|
||||
assert.match(read('public/components/admin.html'), /SearXNG is the only\s*\n?\s*option here that you host yourself/);
|
||||
const mine = read('public/components/my-resources.html');
|
||||
assert.match(mine, /The search query leaves this network/);
|
||||
assert.match(mine, /Do not put anything identifying in the topic/);
|
||||
assert.match(mine, /The query leaves this network/);
|
||||
assert.match(mine, /keep the topic non-identifying/);
|
||||
assert.match(mine, /The query goes to NCBI/);
|
||||
// And it is hidden entirely when unavailable, so nobody ticks a box that
|
||||
// cannot work.
|
||||
assert.match(read('public/js/myResources.js'), /if \(webRow\) webRow\.hidden = !data\.webSearchAvailable;/);
|
||||
// Both option groups are hidden from the same answer, so Generate and Modify
|
||||
// cannot end up offering different things.
|
||||
const js = read('public/js/myResources.js');
|
||||
assert.match(js, /\['mr-web-row', 'web'\]/);
|
||||
assert.match(js, /\['mr-modify-web-row', 'web'\]/);
|
||||
assert.match(js, /\['mr-pubmed-row', 'pubmed'\]/);
|
||||
assert.match(js, /\['mr-modify-pubmed-row', 'pubmed'\]/);
|
||||
assert.match(js, /row\.hidden = !available\[pair\[1\]\]/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue